17 Commits

Author SHA1 Message Date
karim 502fa249a0 gitignore: ignore whole desktop resources dir (web + plugins build artifacts)
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-03 22:00:36 +02:00
karim 6d61c122e1 PROC: activate approach/missed/vector-to-final; DCLTR-2 declutters airspace
PROC menu actions were empty stubs. Now: procedures.js tags approach legs with
seg ('approach'|'missed' — everything past the runway threshold = missed,
previously dropped); Proc.jsx flags loaded legs appr/missed (preserved by
flightplan.setPlan) and the ACTIVATE APPROACH / MISSED APPROACH / VECTOR-TO-FINAL
items set the active (magenta) leg to the matching segment, with a hint when
nothing is loaded. Missed legs shown dimmed in the preview.

DCLTR-2 now hides the airspace (SUA) overlay, matching the manual (p.56), in
addition to the existing nav-symbol declutter.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-03 21:43:36 +02:00
karim fb6f0182cc PFD CDI softkey cycles nav source; demo reflects dataref writes
The CDI softkey was in the PFD root row but had no handler — it never cycled the
HSI/CDI source (GPS↔VLOC1↔VLOC2). Wire it to write cdiSrc
(HSI_source_select_pilot, now writable), cycling GPS→NAV1→NAV2.

Also fix a latent demo bug: the 'needs a live sim socket' guard sat above the
setDataref/command handlers, so in DEMO (no sim socket) every dataref write
(transponder code, baro, AP bugs, CDI) was silently dropped. Reflect writes
locally before that guard so cockpit controls respond without a sim.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-03 20:34:20 +02:00
karim cd7197f06e KAP140: annunciate modes from per-mode status datarefs (same bitfield fix)
The steam-panel KAP140 LCD decoded the same unreliable autopilot_state bitfield
as the G1000 panel, so its lateral/vertical annunciation could be wrong. Read the
*_status datarefs instead, consistent with AutopilotPanel and the PFD.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-02 20:25:52 +02:00
karim 1788e56e65 Autopilot panel: light mode keys from per-mode status datarefs
Key feedback came from decoding the autopilot_state bitfield, whose bit
positions don't reliably match X-Plane (e.g. bit 0 is auto-throttle, not the
flight director), so several keys (FD/NAV/APR/BC/VS/VNV/FLC) never lit even when
the mode was engaged. Read the dedicated *_status datarefs (off/armed/active) —
the same source the PFD AFCS bar uses — so every key and the lateral/vertical
annunciator reflect the real mode. NAV also lights on GPS-coupled (gpss) mode.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-02 17:51:44 +02:00
karim 5b7cf13e9d SVT: align synthetic terrain horizon with the attitude horizon
The 3D terrain showed almost no sky — the horizon sat far above the attitude
horizon line. Base camera pitch was 72°, but with MapLibre's 36.87° vertical FOV
the flat horizon only reaches the attitude line (28% of the SVT box) at ~82°.
Invert the perspective to derive the camera pitch from aircraft pitch so the
synthetic horizon lands exactly on the attitude horizon and tracks it 1:1
(accounting for the 1.5× canvas scale). Raise maxPitch to 85.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-02 16:46:25 +02:00
karim 4a71e5f03d release: only upload artifacts matching the current version (skip stale bundles)
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-02 16:16:43 +02:00
karim 806a82383e release: merge latest.json platforms across split mac/linux releases
So a per-machine release (macOS here, Linux on Arch) of the same version keeps
both platform entries instead of clobbering the one not built this run.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-02 15:20:52 +02:00
karim be424a6c3c Bump desktop app to 0.1.5 (release with Lua auto-install, smoothing, airspace + Linux patchelf fix)
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-02 14:32:57 +02:00
karim 138498956e fix(linux): repair patchelf-corrupted Bun sidecar in AppImage
linuxdeploy injects a RUNPATH ($ORIGIN/../lib) via patchelf into every
usr/bin executable when building the AppDir. The Bun-compiled xpbridge
sidecar (self-contained, JS/assets appended past the ELF) does not
survive ELF rewriting — the patched copy core-dumps on start, so the app
launches but the bridge never listens.

Add scripts/fix-linux-appimage.sh: extract the built AppImage, restore the
pristine repo sidecar, repack with linuxdeploy-plugin-appimage (which does
not patchelf), verify the sidecar is byte-identical, and regenerate the
updater .sig. Wired into scripts/build-linux.sh after `tauri build`.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-02 14:23:59 +02:00
karim 9aba24978b 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>
2026-06-02 13:57:50 +02:00
karim b2fab0c374 G1000 MFD EIS: real two-bus volts, alternator/battery amps, engine hours
- electrical readout now shows M (main) and E (essential) bus volts from
  bus_volts[0]/[1], M (alternator generator_amps) and S (battery_amps) separately,
  and ENG hours from flight time — replacing the hardcoded duplicate volts / +0.0
  amps / 0.0 HRS placeholders (manual S.54 C172 layout)

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-02 12:15:34 +02:00
karim 6738e6085b G1000: deepen VNAV/PFD — V-DEV & VS-TGT chevrons, GPS phase, designated toggle
- PFD VNAV: magenta flight-plan target altitude on the alt scale (S.110), V DEV
  deviation scale + chevron (left, shown in VNAV when not on an ILS), VS TGT
  chevron on the VSI (S.113)
- GPS phase annunciation is now dynamic: APR (approach leg) / TERM (<30 nm to
  destination) / ENR, instead of a fixed label
- flight-plan ALT can be toggled designated(blue) <-> reference(white) by clicking
  the cell (S.106); only designated altitudes drive the VNAV profile
- setPlan now preserves the dsgn/appr waypoint flags across the shared plan
- AFCS vertical mode labelled VPTH (manual) instead of VNV

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-02 12:07:06 +02:00
karim 053d362245 G1000: VNAV descent profile + designated-altitude flight plan colouring
- CURRENT VNV PROFILE panel on the MFD flight-plan page: active VNV waypoint +
  target altitude, VS TGT (−3° path), VS REQ, V DEV, FPA, TIME TO TOD (manual
  S.64 / S.107)
- enriched the VNAV computation (vsTgt / vDev / FPA / time-to-TOD) shared by the
  PFD VnavBox
- flight-plan ALT column now shows designated (VNAV) altitudes in blue (S.105)
- new Audio Panel + earlier manual-alignment batch already in this branch

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-02 06:01:47 +02:00
karim 033a9d406a G1000: manual-accurate radios, baro units, declutter, minimums, OBS, audio panel
Aligned to the official X-Plane 1000 manual:
- NAV radio: active RIGHT / standby LEFT (boxed) per S.12 (COM already correct)
- ALT UNIT softkey (IN / HPA) in the PFD submenu, baro readout converts (S.20)
- DCLTR cycles 3 levels (land / +NDB / flight-plan only) with DCLTR-n label (S.56)
- TOPO and TERRAIN are now independent toggles (relief vs awareness overlay) (S.57)
- Barometric MINIMUMS: BARO MIN bug + readout on the altimeter, amber "MINIMUMS"
  annunciation at/below the decision altitude; set via TMR/REF (lifted to App)
- OBS mode: HSI course follows the CRS knob (magenta "OBS"), sequencing suspended
- New Audio Panel tab (COM mic/receive, MKR/DME/ADF, intercom, Display Backup) (S.91)

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-02 05:55:56 +02:00
karim 38b048ad41 G1000: two-way sim sync, more PFD/MFD fidelity, authentic dialogs
Sync (FlyWithLua companions in plugins/ + server/fmssync.js):
- FMS flight-plan two-way sync (App <-> in-sim FMS) via fms-sync.lua
- G1000 UI-state publish (page/range/inset) via ui-sync.lua + CDI source,
  baro, map-range follow
- Terrain awareness: elevation grid probe (terrain-probe.lua) -> red/yellow
  MFD overlay vs aircraft altitude

PFD:
- AFCS mode annunciation bar from autopilot _status datarefs
- CDI source GPS/VLOC colouring, BRG1/BRG2 pointers + DME windows, marker beacons
- magenta speed/altitude trend vectors, selected-altitude alerting
- time-based (frame-rate-independent) smoothing for attitude/heading/tapes

MFD:
- nav data bar (DTK/ETE/active leg), airways overlay from earth_awy.dat,
  compass rose anchored to the ownship

Dialogs (NEAREST/FLIGHTPLAN/DIRECT-TO/PROCEDURES):
- flat, square, embedded G1000 look (no shadow/rounded/transparency)
- compact lower-right placement, no close X (softkey toggles), single window
- NEAREST 2-line entries (ILS/VFR, COM freq, runway length), PROC action menu

Service worker: network-first HTML so reloads pick up new builds (cache v2).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-02 02:17:06 +02:00
karim 354ea5d44b PFD/cockpit polish + KAP140 autopilot + UI refinements
- PFD: full-screen 2D attitude, G1000 yellow+magenta chevron symbology, rAF
  60fps horizon smoothing, translucent tapes, slimmer softkey bar, header fixes
- Collapsible macOS-dark sidebar (Inter), VFR six-pack + engine cluster + tach
- KAP140 autopilot on the analog page; GMC-710 AFCS tab
- FMS rebuilt as an X-Plane-style CDU; PWA; settings panel (knob mode)

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-01 17:20:16 +02:00
44 changed files with 3018 additions and 402 deletions
+4 -1
View File
@@ -13,7 +13,7 @@ fms-out/
# generated bundle inputs (recreated by scripts/prep-desktop.sh)
desktop/src-tauri/binaries/
desktop/src-tauri/resources/web/
desktop/src-tauri/resources/
# SECRETS — never commit the updater signing private key / password
desktop/.tauri-signing.key
@@ -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/
+1 -1
View File
@@ -5900,7 +5900,7 @@ dependencies = [
[[package]]
name = "xplane-cockpit"
version = "0.1.3"
version = "0.1.5"
dependencies = [
"local-ip-address",
"serde",
+1 -1
View File
@@ -1,6 +1,6 @@
[package]
name = "xplane-cockpit"
version = "0.1.3"
version = "0.1.5"
description = "Desktop launcher for the X-Plane G1000 web cockpit"
authors = ["karim"]
edition = "2021"
+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);
+3 -2
View File
@@ -1,7 +1,7 @@
{
"$schema": "https://schema.tauri.app/config/2",
"productName": "X-Plane Cockpit",
"version": "0.1.3",
"version": "0.1.5",
"identifier": "ch.kgva.xplanecockpit",
"build": {
"frontendDist": "../ui"
@@ -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; }
+38
View File
@@ -0,0 +1,38 @@
# FlyWithLua companion — FMS two-way sync
X-Plane's Web API can't write a flight plan into the FMS. `fms-sync.lua` runs
inside X-Plane (via FlyWithLua, which has the FMS SDK) and syncs the shared
cockpit plan ↔ the in-sim FMS through two files in `Output/fms-sync/`.
## Install (sim PC only)
1. Install **FlyWithLua NG+** (free): copy its plugin folder into
`<X-Plane>/Resources/plugins/FlyWithLua/`.
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. In X-Plane: *FlyWithLua → Reload all Lua script files* (or restart).
The log shows `[glass-cockpit] FMS sync active`.
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
- **App → Sim:** load/build a plan in the web cockpit → it appears in the 3-D
G1000 and the autopilot can fly it.
- **Sim → App:** build/edit the plan in the real FMS → it shows on every tablet.
- **Terrain:** the MFD TERRAIN map colours real scenery elevation red/yellow
relative to your altitude (probed live by `terrain-probe.lua`).
A 3-decimal lat/lon signature de-dupes the round-trip, so the two sides never
loop. Waypoints are pushed as lat/lon legs (exact route; in-sim idents are
generic — route accuracy over cosmetics).
+113
View File
@@ -0,0 +1,113 @@
-- ============================================================================
-- X-Plane Glass Cockpit — FMS two-way sync (FlyWithLua companion)
-- ============================================================================
-- The web cockpit's bridge can't write the FMS via X-Plane's Web API. This
-- script runs INSIDE X-Plane (FlyWithLua) and has the FMS SDK, so it bridges
-- the shared plan <-> the in-sim FMS through two text files:
--
-- <X-Plane>/Output/fms-sync/to_sim.txt written by the bridge (our plan)
-- <X-Plane>/Output/fms-sync/from_sim.txt written here (the sim's plan)
--
-- A 3-decimal lat/lon signature de-dupes both sides so they never loop.
--
-- INSTALL: copy this file to <X-Plane>/Resources/plugins/FlyWithLua/Scripts/
-- (install FlyWithLua NG+ first), then restart X-Plane or run
-- "FlyWithLua > Reload all Lua script files".
-- ============================================================================
local SYNC = SYSTEM_DIRECTORY .. "Output/fms-sync/"
local TO_SIM = SYNC .. "to_sim.txt"
local FROM_SIM = SYNC .. "from_sim.txt"
local last_sig = nil
-- make sure the folder exists (bridge also creates it)
os.execute('mkdir -p "' .. SYNC .. '" 2>/dev/null || mkdir "' .. SYNC .. '" 2>nul')
-- 3-decimal lat/lon signature of a waypoint list ---------------------------
local function sig_of(wps)
local parts = {}
for i = 1, #wps do
parts[i] = string.format("%.3f,%.3f", wps[i].lat, wps[i].lon)
end
return table.concat(parts, ";")
end
local function read_file(p)
local f = io.open(p, "r"); if not f then return nil end
local s = f:read("*a"); f:close(); return s
end
local function write_file(p, s)
local f = io.open(p, "w"); if not f then return end
f:write(s); f:close()
end
-- parse the bridge file: skip "# sig" lines, take "lat lon alt id type" ------
local function parse(txt)
local wps = {}
if not txt then return wps end
for line in txt:gmatch("[^\r\n]+") do
if line:sub(1, 1) ~= "#" then
local lat, lon, alt, id = line:match("^%s*(-?%d+%.?%d*)%s+(-?%d+%.?%d*)%s+(-?%d+)%s+(%S+)")
if lat and lon then
wps[#wps + 1] = { lat = tonumber(lat), lon = tonumber(lon), alt = tonumber(alt) or 0, id = id or "WPT" }
end
end
end
return wps
end
-- read the current in-sim FMS plan ------------------------------------------
local function read_fms()
local wps = {}
local n = XPLMCountFMSEntries()
for i = 0, n - 1 do
-- FlyWithLua: type, id, ref, altitude, lat, lon
local _t, id, _ref, alt, lat, lon = XPLMGetFMSEntryInfo(i)
if lat and lon and (math.abs(lat) > 0.0001 or math.abs(lon) > 0.0001) then
wps[#wps + 1] = { lat = lat, lon = lon, alt = alt or 0, id = (id ~= "" and id) or "WPT" }
end
end
return wps
end
-- write our plan into the in-sim FMS ----------------------------------------
local function apply_to_fms(wps)
local old = XPLMCountFMSEntries()
for i = 1, #wps do
-- lat/lon entries keep our exact coords -> stable round-trip (no drift)
XPLMSetFMSEntryLatLon(i - 1, wps[i].lat, wps[i].lon, math.floor(wps[i].alt or 0))
end
for i = old - 1, #wps, -1 do XPLMClearFMSEntry(i) end -- trim leftovers
if #wps >= 1 then
XPLMSetDisplayedFMSEntry(0)
XPLMSetDestinationFMSEntry(#wps - 1)
end
end
local function serialize(wps)
local lines = { "# " .. sig_of(wps) }
for i = 1, #wps do
lines[#lines + 1] = string.format("%.6f %.6f %d %s WPT", wps[i].lat, wps[i].lon, math.floor(wps[i].alt or 0), wps[i].id)
end
return table.concat(lines, "\n") .. "\n"
end
-- main loop (~1×/sec): whichever side differs from the agreed plan wins ------
function fms_sync_tick()
local to_wps = parse(read_file(TO_SIM))
local tsig = sig_of(to_wps)
local fm_wps = read_fms()
local fsig = sig_of(fm_wps)
if tsig ~= "" and tsig ~= last_sig then
apply_to_fms(to_wps) -- App -> Sim
last_sig = tsig
elseif fsig ~= last_sig then
write_file(FROM_SIM, serialize(fm_wps)) -- Sim -> App
last_sig = fsig
end
end
do_often("fms_sync_tick()")
logMsg("[glass-cockpit] FMS sync active -> " .. SYNC)
+55
View File
@@ -0,0 +1,55 @@
-- ============================================================================
-- X-Plane Glass Cockpit — Terrain awareness probe (FlyWithLua companion)
-- ============================================================================
-- The web MFD can't read X-Plane's scenery elevation over the Web API. This
-- script samples a grid of terrain heights around the aircraft with X-Plane's
-- terrain probe and writes them to terrain.json in the sync folder; the bridge
-- streams it to the tablets, which colour it red/yellow vs aircraft altitude
-- (G1000 TAWS). See terrain-sync in server/fmssync.js.
--
-- INSTALL: copy to <X-Plane>/Resources/plugins/FlyWithLua/Scripts/ (alongside
-- fms-sync.lua). Needs FlyWithLua NG+ (XPLM scenery-probe bindings).
-- ============================================================================
local SYNC = SYSTEM_DIRECTORY .. "Output/fms-sync/"
local OUT = SYNC .. "terrain.json"
os.execute('mkdir -p "' .. SYNC .. '" 2>/dev/null || mkdir "' .. SYNC .. '" 2>nul')
local M_FT = 3.28084
local ROWS, COLS = 24, 24
local DLAT, DLON = 0.35, 0.5 -- half-box (deg) around the aircraft
local probe = XPLMCreateProbe(0) -- xplm_ProbeY
-- terrain elevation (ft MSL) at a lat/lon, via the vertical scenery probe
local function elev_ft(lat, lon)
local x, y, z = XPLMWorldToLocal(lat, lon, 0)
local res, _px, py = XPLMProbeTerrainXYZ(probe, x, y, z)
if res ~= 0 then return 0 end -- 0 = xplm_ProbeHitTerrain
local _plat, _plon, palt = XPLMLocalToWorld(x, py, z)
return math.max(0, math.floor(palt * M_FT))
end
function gc_terrain_tick()
local lat = get("sim/flightmodel/position/latitude")
local lon = get("sim/flightmodel/position/longitude")
local alt = math.floor(get("sim/flightmodel/position/elevation") * M_FT) -- true MSL
local n, s = lat + DLAT, lat - DLAT
local w, e = lon - DLON, lon + DLON
local cells = {}
for r = 0, ROWS - 1 do -- r = 0 → north (top)
local glat = n - (r / (ROWS - 1)) * (n - s)
for c = 0, COLS - 1 do -- c = 0 → west
local glon = w + (c / (COLS - 1)) * (e - w)
cells[#cells + 1] = elev_ft(glat, glon)
end
end
local f = io.open(OUT, "w")
if not f then return end
f:write(string.format(
'{"lat":%.5f,"lon":%.5f,"alt":%d,"n":%.5f,"s":%.5f,"w":%.5f,"e":%.5f,"rows":%d,"cols":%d,"elev":[%s]}',
lat, lon, alt, n, s, w, e, ROWS, COLS, table.concat(cells, ",")))
f:close()
end
do_often("gc_terrain_tick()") -- ~1×/sec
logMsg("[glass-cockpit] terrain probe active -> " .. OUT)
+66
View File
@@ -0,0 +1,66 @@
-- ============================================================================
-- X-Plane Glass Cockpit — G1000 UI-state publisher (FlyWithLua companion)
-- ============================================================================
-- The web G1000 mirrors the in-sim G1000's display state. Most of it already
-- flows over the Web API (attitude, radios, AP, CDI source, baro, ...). The few
-- bits that are G1000-internal (MFD page, map range, PFD inset) aren't standard
-- datarefs, so this script reads them and re-publishes them under our own
-- namespace, which the bridge then streams to every tablet:
--
-- glasscockpit/ui/mfd_page Int 0 = MAP, 1 = FPL, 2 = NRST
-- glasscockpit/ui/map_range_nm Float active map range in NM
-- glasscockpit/ui/inset Int PFD inset map on/off (0/1)
--
-- INSTALL: copy to <X-Plane>/Resources/plugins/FlyWithLua/Scripts/ (alongside
-- fms-sync.lua). The web app follows these when present and falls back to its
-- own local control when they're absent — so it never breaks without the plugin.
-- ============================================================================
-- our published values (the create_dataref callbacks read these) ------------
local ui_mfd_page = -1 -- -1 = "unknown" -> web keeps local control
local ui_map_range_nm = -1
local ui_inset = -1
create_dataref("glasscockpit/ui/mfd_page", "Int", function() return ui_mfd_page end)
create_dataref("glasscockpit/ui/map_range_nm", "Float", function() return ui_map_range_nm end)
create_dataref("glasscockpit/ui/inset", "Int", function() return ui_inset end)
-- safe optional dataref readers (nil if the dataref doesn't exist) ----------
local function geti(name) local h = XPLMFindDataRef(name); if h then return XPLMGetDatai(h) end end
local function getf(name) local h = XPLMFindDataRef(name); if h then return XPLMGetDataf(h) end end
-- ============================================================================
-- TODO (confirm in YOUR sim): the exact G1000 source datarefs differ per
-- aircraft. Run the probe below once, read the X-Plane Log.txt, and plug the
-- right names in here. Until then these stay -1 and the web app uses its own
-- local page/range/inset (no harm).
-- ============================================================================
local function read_g1000_state()
-- MAP RANGE — many G1000s expose an NM range or an enum index. Try a couple
-- of common candidates; map_range may be an enum needing a lookup table.
local rng = getf("sim/cockpit2/EFIS/map_range") -- <-- verify name
if rng then ui_map_range_nm = rng end
-- PFD INSET on/off — G1000-internal, name varies:
-- local ins = geti("sim/cockpit2/EFIS/inset_map_on") -- <-- verify name
-- if ins then ui_inset = ins end
-- MFD PAGE group — G1000-internal, name varies:
-- local pg = geti("sim/cockpit2/EFIS/mfd_page") -- <-- verify name
-- if pg then ui_mfd_page = pg end
end
do_often("read_g1000_state()")
-- ---- one-shot probe: log every dataref whose name contains a keyword -------
-- Bind to a key/macro, fire once, then read Log.txt to discover the real names.
function gc_probe_g1000()
local hits = {}
for _, kw in ipairs({ "EFIS", "g1000", "GPS/g1000", "map_range", "inset", "mfd" }) do
logMsg("[glass-cockpit] probe keyword: " .. kw .. " (search Log.txt / DataRefEditor)")
end
logMsg("[glass-cockpit] tip: use the DataRefEditor or DataRefTool plugin and filter for 'EFIS' / 'g1000' to find map-range / inset / page datarefs, then edit ui-sync.lua")
end
add_macro("Glass Cockpit: probe G1000 datarefs", "gc_probe_g1000()")
logMsg("[glass-cockpit] UI-state publisher active (mfd_page / map_range_nm / inset)")
+6
View File
@@ -34,6 +34,12 @@ docker run --rm --platform linux/amd64 \
"$IMG" \
bash -c "export PATH=/usr/local/cargo/bin:\$PATH; tauri build --target x86_64-unknown-linux-gnu --bundles $BUNDLES $CFG"
# Repair the Bun sidecar that linuxdeploy's patchelf corrupts inside the AppImage
# (see scripts/fix-linux-appimage.sh). Runs on the host against the mounted
# artifacts; regenerates the updater .sig when a signing key is present.
echo "==> repairing AppImage sidecar"
bash "$ROOT/scripts/fix-linux-appimage.sh"
echo "==> artifacts:"
find target-linux/x86_64-unknown-linux-gnu/release/bundle -maxdepth 2 -type f \
\( -name '*.AppImage' -o -name '*.deb' -o -name '*.AppImage.sig' -o -name '*.tar.gz' -o -name '*.sig' \) 2>/dev/null
+69
View File
@@ -0,0 +1,69 @@
#!/usr/bin/env bash
# Post-process a Tauri-built Linux AppImage to repair the Bun sidecar.
#
# WHY: Tauri's AppImage step runs linuxdeploy, which patchelf-injects a RUNPATH
# ($ORIGIN/../lib) into every executable in usr/bin. The Bun-compiled sidecar
# `xpbridge` is a self-contained binary (~91 MB, JS/assets appended past the ELF)
# and does NOT survive ELF rewriting — the patched copy core-dumps on start, so
# the app launches but the bridge never listens. The repo/standalone binary is
# fine; only the AppImage copy is corrupt.
#
# FIX: replace the patched sidecar in the AppImage with the pristine repo binary,
# repack (the appimage plugin does NOT patchelf), and regenerate the updater .sig.
#
# Run AFTER `tauri build ... --bundles appimage[,updater]`. Idempotent.
set -euo pipefail
ROOT="$(cd "$(dirname "$0")/.." && pwd)"
cd "$ROOT"
TARGET_DIR="${CARGO_TARGET_DIR:-$ROOT/target-linux}"
BUNDLE_DIR="$TARGET_DIR/x86_64-unknown-linux-gnu/release/bundle/appimage"
ORIG_SIDECAR="$ROOT/desktop/src-tauri/binaries/xpbridge-x86_64-unknown-linux-gnu"
PLUGIN="$HOME/.cache/tauri/linuxdeploy-plugin-appimage.AppImage"
[[ -f "$ORIG_SIDECAR" ]] || { echo "!! pristine sidecar missing: $ORIG_SIDECAR (run prep-desktop.sh)"; exit 1; }
[[ -x "$PLUGIN" ]] || { echo "!! linuxdeploy-plugin-appimage missing: $PLUGIN (run a tauri appimage build once to fetch it)"; exit 1; }
APPIMAGE="$(find "$BUNDLE_DIR" -maxdepth 1 -name '*.AppImage' | head -1)"
[[ -n "$APPIMAGE" ]] || { echo "!! no AppImage found in $BUNDLE_DIR"; exit 1; }
echo "==> repairing sidecar in: $(basename "$APPIMAGE")"
WORK="$(mktemp -d)"; trap 'rm -rf "$WORK"' EXIT
( cd "$WORK" && APPIMAGE_EXTRACT_AND_RUN=1 "$APPIMAGE" --appimage-extract >/dev/null )
APPDIR="$WORK/squashfs-root"
SC="$(find "$APPDIR" -name xpbridge -type f | head -1)"
[[ -n "$SC" ]] || { echo "!! xpbridge not found inside AppImage"; exit 1; }
cp -f "$ORIG_SIDECAR" "$SC"; chmod +x "$SC"
echo "==> restored pristine sidecar ($(sha256sum "$SC" | cut -c1-12)...)"
# Repack. The appimage plugin embeds the AppDir verbatim — no patchelf — so the
# sidecar stays byte-identical to the repo binary.
OUT="$WORK/out.AppImage"
( cd "$WORK" && APPIMAGE_EXTRACT_AND_RUN=1 NO_STRIP=1 ARCH=x86_64 LDAI_OUTPUT="$OUT" \
"$PLUGIN" --appdir "$APPDIR" >/dev/null )
[[ -f "$OUT" ]] || { echo "!! repack produced no AppImage"; exit 1; }
# sanity: the repacked sidecar must run, not core-dump
( cd "$WORK" && rm -rf v && mkdir v && cd v && APPIMAGE_EXTRACT_AND_RUN=1 "$OUT" --appimage-extract >/dev/null )
NEWSC="$(find "$WORK/v/squashfs-root" -name xpbridge -type f | head -1)"
if [[ "$(sha256sum "$NEWSC" | cut -d' ' -f1)" != "$(sha256sum "$ORIG_SIDECAR" | cut -d' ' -f1)" ]]; then
echo "!! repacked sidecar hash != pristine — repack still mangled it"; exit 1
fi
echo "==> verified: repacked sidecar is byte-identical to repo binary"
mv -f "$OUT" "$APPIMAGE"
echo "==> replaced original AppImage (same name, updater url unchanged)"
# Regenerate the updater signature for the new file, if signing is configured.
SIG="$APPIMAGE.sig"
if [[ -f "$ROOT/desktop/.tauri-signing.key" && -f "$ROOT/desktop/.tauri-signing.pw" ]]; then
# signer writes <FILE>.sig next to the input automatically
TAURI_SIGNING_PRIVATE_KEY="$(cat "$ROOT/desktop/.tauri-signing.key")" \
TAURI_SIGNING_PRIVATE_KEY_PASSWORD="$(cat "$ROOT/desktop/.tauri-signing.pw")" \
npx --prefix desktop tauri signer sign "$APPIMAGE" >/dev/null
echo "==> regenerated updater signature: $(basename "$SIG")"
else
[[ -f "$SIG" ]] && { rm -f "$SIG"; echo "==> no signing key — removed stale $(basename "$SIG")"; }
fi
echo "==> done. AppImage repaired."
+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 \
+23 -3
View File
@@ -40,7 +40,12 @@ function findArtifacts() {
if (fs.existsSync(sig)) out.updater[platformKey] = { file, sig: fs.readFileSync(sig, 'utf8').trim() };
}
};
const glob1 = (dir, re) => fs.existsSync(dir) ? fs.readdirSync(dir).filter((f) => re.test(f)).map((f) => path.join(dir, f)) : [];
// Guard against stale bundles from older builds lingering in the output dirs:
// any artifact whose filename carries a version must match the current one.
// (The .app.tar.gz has no version in its name, so it's always accepted.)
const verRe = new RegExp(`_${VERSION.replace(/\./g, '\\.')}_`);
const verOk = (f) => !/_\d+\.\d+\.\d+_/.test(path.basename(f)) || verRe.test(path.basename(f));
const glob1 = (dir, re) => fs.existsSync(dir) ? fs.readdirSync(dir).filter((f) => re.test(f)).map((f) => path.join(dir, f)).filter(verOk) : [];
// macOS
glob1(path.join(macBundle, 'macos'), /\.app\.tar\.gz$/).forEach((f) => add(f, 'darwin-aarch64'));
glob1(path.join(macBundle, 'dmg'), /\.dmg$/).forEach((f) => out.assets.push(f));
@@ -100,15 +105,30 @@ async function main() {
for (const [key, { file, sig }] of Object.entries(updater)) {
platforms[key] = { signature: sig, url: dlUrl(verTag, path.basename(file)) };
}
// Merge with the currently-published latest.json so a split release (e.g. macOS
// on one machine, Linux on another) doesn't drop the platform we didn't build
// this run — as long as both build the SAME version. A published older version
// is ignored (a fresh version starts clean; the other platform re-publishes).
let merged = platforms;
try {
const cur = await fetch(dlUrl(UPDATER_TAG, 'latest.json')).then((r) => (r.ok ? r.json() : null));
if (cur && cur.version === VERSION && cur.platforms) {
const kept = Object.keys(cur.platforms).filter((k) => !platforms[k]);
merged = { ...cur.platforms, ...platforms };
if (kept.length) console.log('Merged kept platforms from published latest.json:', kept.join(', '));
}
} catch { /* offline / first ever release */ }
const latest = {
version: VERSION,
notes: `X-Plane Cockpit ${verTag}`,
pub_date: new Date().toISOString(),
platforms,
platforms: merged,
};
const latestPath = path.join(ROOT, 'desktop/latest.json');
fs.writeFileSync(latestPath, JSON.stringify(latest, null, 2));
console.log('latest.json platforms:', Object.keys(platforms).join(', ') || '(none)');
console.log('latest.json platforms:', Object.keys(merged).join(', ') || '(none)');
// Upload latest.json to the fixed "updater" release (constant endpoint URL).
const upd = await ensureRelease(UPDATER_TAG, 'Updater channel', 'Rolling pointer used by the in-app updater.');
+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) }));
}
+102 -15
View File
@@ -11,9 +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 } 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
@@ -33,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
@@ -46,7 +50,9 @@ function broadcast(obj) {
}
function broadcastPlan() {
broadcast({ type: 'flightplan', data: fp.getPlan() });
const plan = fp.getPlan();
broadcast({ type: 'flightplan', data: plan });
pushToSim(plan); // hand the plan to the FlyWithLua FMS bridge (App → Sim)
}
async function fetchAllByName(resource, names) {
@@ -162,6 +168,11 @@ function handleClientMessage(msg) {
}
if (msg.type === 'fp_remove') { fp.removeWaypoint(msg.index); return broadcastPlan(); }
if (msg.type === 'fp_active') { fp.setActiveLeg(msg.index); return broadcastPlan(); }
if (msg.type === 'fp_load') {
const r = fp.loadFms(msg.name);
if (r.ok) return broadcastPlan();
return broadcast({ type: 'fp_export_result', ...r });
}
if (msg.type === 'fp_clear') { fp.setPlan({ waypoints: [] }); return broadcastPlan(); }
if (msg.type === 'fp_export') {
const r = fp.exportFms(msg.name || 'WEBFPL');
@@ -169,6 +180,16 @@ function handleClientMessage(msg) {
return;
}
// Demo / no-sim: reflect dataref writes locally so cockpit controls (CDI,
// transponder, baro, AP bugs …) still respond, then stop — there's no sim to
// forward to. Must run BEFORE the live-socket guard below.
if (msg.type === 'setDataref' && (process.env.DEMO || !state.xpSocket)) {
const name = WRITABLE_DATAREFS[msg.name];
if (!name) return log(`! unknown writable dataref alias: ${msg.name}`);
state.values[msg.name] = Number(msg.value);
return broadcast({ type: 'values', data: { [msg.name]: Number(msg.value) } });
}
// --- everything below talks to X-Plane; needs a live sim socket ---
if (!state.xpSocket || state.xpSocket.readyState !== WsClient.OPEN) return;
@@ -185,8 +206,9 @@ function handleClientMessage(msg) {
);
} 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}`);
if (!name) return log(`! unknown writable dataref alias: ${msg.name}`);
const id = state.drefNameToId.get(name);
if (id == null) return log(`! writable dataref not resolved yet: ${msg.name}`);
state.xpSocket.send(
JSON.stringify({
req_id: state.reqId++,
@@ -203,8 +225,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).
@@ -216,6 +244,23 @@ app.get('/api/nav/bbox', (req, res) =>
res.json(navBbox(+req.query.s, +req.query.w, +req.query.n, +req.query.e,
(req.query.types || 'apt,vor,ndb').split(','), +req.query.limit || 800))
);
// Airways (Victor/Jet routes) inside a map window — for the MFD AIRWAYS overlay.
app.get('/api/nav/airways', (req, res) =>
res.json(navAirways(+req.query.s, +req.query.w, +req.query.n, +req.query.e, +req.query.limit || 500))
);
// 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))
@@ -227,6 +272,8 @@ app.get('/api/nav/procs', (req, res) => {
if (!p) return res.status(404).json({ error: 'no procedures for ' + req.query.icao });
res.json({ icao: p.icao, runways: p.runways, sids: p.sids, stars: p.stars, approaches: p.approaches });
});
// Saved flight plans (Output/FMS plans) — list for the FPL "load" picker.
app.get('/api/fms/list', (_req, res) => res.json(fp.listPlans()));
app.get('/api/nav/proc', (req, res) =>
res.json(procLegs(String(req.query.icao || ''), req.query.type, req.query.name, req.query.trans))
);
@@ -261,17 +308,23 @@ function startDemo() {
heading: 87, slip: 0.3, gForce: 1.04, oat: 9,
apState: (1 << 0) | (1 << 1) | (1 << 14), // FD + HDG + ALT
apEngaged: 1, apHdgBug: 90, apAltBug: 6000, apVsBug: 500, apSpdBug: 120,
// AFCS annunciation: AP on, HDG active + GPS armed (lateral), ALT active (vertical)
apMode: 2, hdgStatus: 2, gpssStatus: 1, altStatus: 2,
lat: 47.45, lon: -122.31, track: 90, groundspeed: 64, gpsDistNm: 18.4, gpsBearing: 92,
// radios (XP freq units: nav/com in 10 kHz, e.g. 11030 = 110.30)
nav1: 11030, nav1Sb: 11150, nav2: 11380, nav2Sb: 10890,
nav1: 11380, nav1Sb: 11150, nav2: 11030, nav2Sb: 10890,
com1: 12190, com1Sb: 13000, com2: 12475, com2Sb: 12180,
// HSI / data fields
obsCrs: 175, hsiDef: -0.6, hsiToFrom: 1, navBearing: 168, gsDef: 0.7,
nav1Brg: 210, nav1Dme: 12.4, nav2Brg: 320, nav2Dme: 0, // BRG1 (NAV1 VOR/DME) demo
baro: 29.92, tas: 131, windSpd: 14, windDir: 240,
xpdrCode: 1200, xpdrMode: 2, fdPitch: 5, fdRoll: -10,
cdiSrc: Number(process.env.DEMO_CDI ?? 2), // 0 VLOC1, 1 VLOC2, 2 GPS
...(process.env.DEMO_RANGE ? { uiMapRange: Number(process.env.DEMO_RANGE) } : {}),
// engine strip (arrays, like the sim)
engRpm: [2410], fuelFlow: [0.0072], oilTemp: [88], oilPress: [52], egt: [720],
fuelQty: [60, 58], volts: [28.0], amps: [12],
fuelQty: [60, 58], volts: [process.env.DEMO_ALERT ? 23.4 : 28.0, 27.8], amps: [-1.5], genAmps: [20.5], engHrs: 5040,
});
// a sample plan so the map/FMS show something in demo mode
fp.setPlan({ name: 'DEMO', waypoints: [
@@ -279,27 +332,61 @@ function startDemo() {
{ id: 'SEA', lat: 47.435, lon: -122.310, type: 'VOR', alt: 4000 },
{ id: 'KPDX', lat: 45.589, lon: -122.597, type: 'APT', alt: 1200 },
]});
pushToSim(fp.getPlan());
let t = 0;
const lat0 = 47.45, lon0 = -122.31, R = 0.05, w = 0.02; // gentle orbit around KSEA
const cosL = Math.cos(lat0 * Math.PI / 180);
let pLat = lat0, pLon = lon0;
setInterval(() => {
t += 0.1;
state.values.roll = -12 + Math.sin(t) * 4;
state.values.pitch = 4.5 + Math.cos(t * 0.7) * 1.5;
state.values.heading = (87 + Math.sin(t * 0.3) * 3 + 360) % 360;
state.values.track = state.values.heading;
state.values.altitude = 5500 + Math.sin(t * 0.5) * 40;
state.values.airspeed = 124 + Math.sin(t * 0.4) * 3;
// creep south-east so the aircraft visibly moves on the map
state.values.lat -= 0.0006;
state.values.lon -= 0.0009;
const newAlt = 5500 + Math.sin(t * 0.5) * 120;
state.values.vspeed = (newAlt - state.values.altitude) / (0.1 / 60); // fpm from Δalt/Δt
state.values.altitude = newAlt;
state.values.airspeed = 124 + Math.sin(t * 0.4) * 8;
// orbit so the aircraft visibly moves but stays near the demo flight plan
const lat = lat0 + Math.cos(t * w) * R;
const lon = lon0 + Math.sin(t * w) * R / cosL;
const trk = (Math.atan2((lon - pLon) * cosL, lat - pLat) * 180 / Math.PI + 360) % 360;
state.values.lat = lat; state.values.lon = lon;
state.values.track = trk; state.values.heading = trk;
pLat = lat; pLon = lon;
broadcast({ type: 'status', xpConnected: true });
broadcast({ type: 'values', data: state.values });
}, 100);
// synthetic terrain grid (a Cascades-style ridge rising eastward) so the MFD
// terrain-awareness colouring (yellow/red vs aircraft altitude) is visible
const emitTerrain = () => {
const lat = state.values.lat, lon = state.values.lon, alt = state.values.altitude;
const rows = 28, cols = 28, n = lat + 0.35, s = lat - 0.35, w = lon - 0.5, e = lon + 0.5;
const elev = [];
for (let r = 0; r < rows; r++) for (let c = 0; c < cols; c++) {
const fx = c / (cols - 1), fy = r / (rows - 1); // fx: 0 west → 1 east
let h = fx * 9000 - 1200 + Math.sin(fy * 6 + fx * 4) * 800 + Math.cos(fx * 9) * 400;
elev.push(Math.max(0, Math.round(h)));
}
broadcast({ type: 'terrain', data: { lat, lon, alt, n, s, w, e, rows, cols, elev } });
};
emitTerrain();
setInterval(emitTerrain, 1500);
}
server.listen(CONFIG.bridgePort, CONFIG.bridgeHost, () => {
log(`Bridge UI: http://${CONFIG.bridgeHost}:${CONFIG.bridgePort}`);
log(`On tablets: http://<this-PC-LAN-IP>:${CONFIG.bridgePort}`);
loadNavData(); // async; FMS resolves idents once ready
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(),
onSimPlan: (waypoints) => { fp.setPlan({ name: 'ACTIVE', waypoints, activeLeg: 1 }); broadcastPlan(); },
});
// Terrain awareness grid (from the FlyWithLua terrain probe) → MFD colouring
startTerrainSync((t) => broadcast({ type: 'terrain', data: t }));
if (process.env.DEMO) startDemo();
else connectXPlane();
});
+50 -2
View File
@@ -60,6 +60,25 @@ export const DATAREFS = {
hsiToFrom: 'sim/cockpit2/radios/indicators/hsi_flag_from_to_pilot',
navBearing: 'sim/cockpit2/radios/indicators/hsi_bearing_deg_mag_pilot',
// --- bearing pointers (BRG1/BRG2) + DME + marker beacons ---
nav1Brg: 'sim/cockpit2/radios/indicators/nav1_bearing_deg_mag',
nav2Brg: 'sim/cockpit2/radios/indicators/nav2_bearing_deg_mag',
nav1Dme: 'sim/cockpit2/radios/indicators/nav1_dme_distance_nm',
nav2Dme: 'sim/cockpit2/radios/indicators/nav2_dme_distance_nm',
mkrOuter: 'sim/cockpit2/radios/indicators/outer_marker_lit',
mkrMiddle: 'sim/cockpit2/radios/indicators/middle_marker_lit',
mkrInner: 'sim/cockpit2/radios/indicators/inner_marker_lit',
// --- G1000 UI state (for display sync with the in-sim G1000) ---
// CDI/HSI source: 0 = NAV1/VLOC1, 1 = NAV2/VLOC2, 2 = GPS (standard dataref).
cdiSrc: 'sim/cockpit2/radios/actuators/HSI_source_select_pilot',
// The rest are G1000-internal, so the FlyWithLua companion (ui-sync.lua)
// publishes them as custom datarefs. Absent until the plugin runs -> the web
// G1000 just keeps its own local UI state (graceful).
uiMfdPage: 'glasscockpit/ui/mfd_page', // 0 map, 1 fpl, 2 nrst
uiMapRange: 'glasscockpit/ui/map_range_nm', // active map range, NM
uiInset: 'glasscockpit/ui/inset', // PFD inset map on/off (0/1)
// --- G1000 PFD: data fields ---
baro: 'sim/cockpit2/gauges/actuators/barometer_setting_in_hg_pilot',
tas: 'sim/cockpit2/gauges/indicators/true_airspeed_kts_pilot',
@@ -77,8 +96,10 @@ export const DATAREFS = {
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',
volts: 'sim/cockpit2/electrical/bus_volts', // array: [0]=main bus, [1]=essential
amps: 'sim/cockpit2/electrical/battery_amps', // battery (S) amps
genAmps: 'sim/cockpit2/electrical/generator_amps', // alternator (M) amps
engHrs: 'sim/time/total_flight_time_sec', // proxy for engine/tach hours
// --- autopilot readouts (live values, so the panel reflects reality) ---
apState: 'sim/cockpit2/autopilot/autopilot_state', // bitfield of active modes
@@ -88,6 +109,21 @@ export const DATAREFS = {
apSpdBug: 'sim/cockpit2/autopilot/airspeed_dial_kts_mach',
apEngaged: 'sim/cockpit2/autopilot/servos_on',
navHdef: 'sim/cockpit2/radios/indicators/hsi_relative_bearing_vor_pilot',
// --- AFCS mode annunciation (the green/white mode strip on a real G1000) ---
// X-Plane's per-mode status datarefs: 0 = off, 1 = armed, 2 = active/captured.
// These mean the AFCS bar mirrors the sim exactly, no Lua needed.
apMode: 'sim/cockpit2/autopilot/autopilot_mode', // 0 off, 1 FD, 2 AP
hdgStatus: 'sim/cockpit2/autopilot/hdg_status',
navStatus: 'sim/cockpit2/autopilot/nav_status',
gpssStatus: 'sim/cockpit2/autopilot/gpss_status',
aprStatus: 'sim/cockpit2/autopilot/approach_status',
bcStatus: 'sim/cockpit2/autopilot/backcourse_status',
altStatus: 'sim/cockpit2/autopilot/alt_hold_status',
vsStatus: 'sim/cockpit2/autopilot/vvi_status',
flcStatus: 'sim/cockpit2/autopilot/speed_status',
gsStatus: 'sim/cockpit2/autopilot/glideslope_status',
vnavStatus: 'sim/cockpit2/autopilot/vnav_status',
};
// Datarefs the frontend may WRITE (e.g. turning the heading bug knob).
@@ -98,6 +134,7 @@ export const WRITABLE_DATAREFS = {
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
cdiSrc: 'sim/cockpit2/radios/actuators/HSI_source_select_pilot', // 0 NAV1 · 1 NAV2 · 2 GPS (CDI softkey cycles it)
};
// Commands the frontend may TRIGGER (autopilot mode buttons etc.).
@@ -121,6 +158,17 @@ export const COMMANDS = {
xpdrIdent: 'sim/transponder/transponder_ident',
};
// Per-radio standby tuning (coarse = MHz, fine = kHz) + active/standby flip.
// These work regardless of the dataref's frequency units, so the web tuner just
// fires them — no risky raw frequency writes.
for (const r of ['nav1', 'nav2', 'com1', 'com2']) {
COMMANDS[`${r}CoarseUp`] = `sim/radios/stby_${r}_coarse_up`;
COMMANDS[`${r}CoarseDown`] = `sim/radios/stby_${r}_coarse_down`;
COMMANDS[`${r}FineUp`] = `sim/radios/stby_${r}_fine_up`;
COMMANDS[`${r}FineDown`] = `sim/radios/stby_${r}_fine_down`;
COMMANDS[`${r}Swap`] = `sim/radios/${r}_standby_flip`;
}
// Every clickable G1000 bezel control maps to a real X-Plane command. The PFD
// is unit n1, the MFD is unit n3 (the default C172 layout). Aliases are
// prefixed pfd_/mfd_ so the frontend just says e.g. command('mfd_fpl').
+41 -4
View File
@@ -18,7 +18,7 @@ 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 }))
.map((w) => ({ id: String(w.id || 'WPT'), lat: +w.lat, lon: +w.lon, type: w.type || 'WPT', alt: w.alt ?? null, ...(w.dsgn != null ? { dsgn: !!w.dsgn } : {}), ...(w.appr ? { appr: true } : {}), ...(w.missed ? { missed: true } : {}) }))
: [];
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 };
@@ -87,14 +87,51 @@ export function exportFms(name = 'WEBFPL') {
}
const content = lines.join('\n') + '\n';
const root = xplaneRoot();
const dir = root ? path.join(root, 'Output', 'FMS plans') : path.join(process.cwd(), 'fms-out');
const dir = fmsDir();
try {
fs.mkdirSync(dir, { recursive: true });
const file = path.join(dir, `${name}.fms`);
fs.writeFileSync(file, content);
return { ok: true, file, intoXplane: !!root };
return { ok: true, file, intoXplane: !!xplaneRoot() };
} catch (e) {
return { ok: false, error: e.message };
}
}
// ---- load saved X-Plane .fms plans (Output/FMS plans) ----
function fmsDir() {
const root = xplaneRoot();
return root ? path.join(root, 'Output', 'FMS plans') : path.join(process.cwd(), 'fms-out');
}
const FMS_TYPE = { 1: 'APT', 2: 'NDB', 3: 'VOR', 11: 'WPT', 28: 'USR' };
// List the names of every saved .fms plan (X-Plane's own + our exports).
export function listPlans() {
try {
return fs.readdirSync(fmsDir())
.filter((f) => f.toLowerCase().endsWith('.fms'))
.map((f) => f.replace(/\.fms$/i, ''))
.sort((a, b) => a.localeCompare(b));
} catch { return []; }
}
// Parse a saved .fms (v1100/v3) into our waypoints and make it the active plan.
export function loadFms(name) {
const safe = String(name || '').replace(/[^\w .+-]/g, '');
const file = path.join(fmsDir(), `${safe}.fms`);
if (!fs.existsSync(file)) return { ok: false, error: `not found: ${safe}` };
const wps = [];
for (const raw of fs.readFileSync(file, 'utf8').split(/\r?\n/)) {
const p = raw.trim().split(/\s+/);
// waypoint rows start with a numeric type code: <type> <ident> <alt> <lat> <lon>
if (p.length >= 5 && /^\d+$/.test(p[0]) && p[0] !== '1100') {
const lat = parseFloat(p[3]), lon = parseFloat(p[4]), alt = parseFloat(p[2]);
if (isFinite(lat) && isFinite(lon)) {
wps.push({ id: p[1], lat, lon, type: FMS_TYPE[+p[0]] || 'WPT', alt: alt > 0 ? Math.round(alt) : null });
}
}
}
if (wps.length < 1) return { ok: false, error: 'no waypoints in file' };
setPlan({ name: safe.toUpperCase(), waypoints: wps, activeLeg: 1 });
return { ok: true, plan, count: wps.length };
}
+85
View File
@@ -0,0 +1,85 @@
// Two-way flight-plan sync with X-Plane's in-sim FMS, bridged by a FlyWithLua
// companion script (see plugins/fms-sync.lua). X-Plane's Web API can't inject a
// flight plan into the FMS, so the Lua script (which has the FMS SDK) does it.
//
// Channel = two text files in <X-Plane>/Output/fms-sync/ (bridge + Lua run on
// the same PC). We write to_sim.txt (our plan); Lua applies it to the FMS and
// writes from_sim.txt (the sim's plan); we adopt sim-side changes. A position
// signature (3-decimal lat/lon) de-dupes so the two sides never loop.
import fs from 'node:fs';
import path from 'node:path';
import { xplaneRoot } from './navdata.js';
function dir() {
const r = xplaneRoot();
return r ? path.join(r, 'Output', 'fms-sync') : path.join(process.cwd(), 'fms-sync');
}
const toSimFile = () => path.join(dir(), 'to_sim.txt');
const fromSimFile = () => path.join(dir(), 'from_sim.txt');
// loop-guard signature: rounded lat/lon list (idents/alt ignored, and coords
// from our navdata == X-Plane's, so it stays stable across the round-trip)
const sig = (wps) => (wps || []).map((w) => `${(+w.lat).toFixed(3)},${(+w.lon).toFixed(3)}`).join(';');
let lastSig = null;
function serialize(wps) {
const body = (wps || [])
.map((w) => `${(+w.lat).toFixed(6)} ${(+w.lon).toFixed(6)} ${Math.round(w.alt || 0)} ${w.id || 'WPT'} ${w.type || 'WPT'}`)
.join('\n');
return `# ${sig(wps)}\n${body}\n`; // first line = sig comment, then waypoints
}
function parse(txt) {
const wps = [];
for (const ln of (txt || '').split(/\r?\n/)) {
const t = ln.trim();
if (!t || t.startsWith('#')) continue; // skip sig/comment line
const p = t.split(/\s+/);
const lat = +p[0], lon = +p[1];
if (p.length >= 2 && isFinite(lat) && isFinite(lon) && Math.abs(lat) <= 90 && Math.abs(lon) <= 180) {
const alt = +p[2] || 0;
wps.push({ id: p[3] || 'WPT', lat, lon, type: p[4] || 'WPT', alt: alt > 0 ? alt : null });
}
}
return wps;
}
// our plan changed → hand it to the Lua script
export function pushToSim(plan) {
try {
fs.mkdirSync(dir(), { recursive: true });
fs.writeFileSync(toSimFile(), serialize(plan?.waypoints || []));
lastSig = sig(plan?.waypoints || []);
} catch { /* sim not local / no write access */ }
}
// Terrain elevation grid published by the FlyWithLua terrain probe
// (terrain.json in the sync dir). Polled and broadcast so the MFD can colour
// terrain awareness (red/yellow). Only re-broadcasts when it actually changes.
const terrainFile = () => path.join(dir(), 'terrain.json');
export function startTerrainSync(onTerrain, intervalMs = 1500) {
let lastMtime = 0;
setInterval(() => {
let st;
try { st = fs.statSync(terrainFile()); } catch { return; }
if (st.mtimeMs === lastMtime) return;
lastMtime = st.mtimeMs;
try {
const t = JSON.parse(fs.readFileSync(terrainFile(), 'utf8'));
if (t && Array.isArray(t.elev) && t.elev.length) onTerrain(t);
} catch { /* mid-write / malformed */ }
}, intervalMs);
}
// poll the Lua-written sim plan; adopt genuine sim-side changes
export function startFmsSync({ getPlan, onSimPlan }) {
pushToSim(getPlan());
setInterval(() => {
let txt;
try { txt = fs.readFileSync(fromSimFile(), 'utf8'); } catch { return; }
const wps = parse(txt);
const s = sig(wps);
if (wps.length && s && s !== lastSig) { lastSig = s; onSimPlan(wps); }
}, 1200);
}
+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;
}
+81 -7
View File
@@ -41,12 +41,15 @@ const airports = []; // { id, lat, lon, name, elev }
const navaids = []; // { id, lat, lon, type:'VOR'|'NDB', freq, name }
const fixCells = new Map(); // "ilat,ilon" -> [{ id, lat, lon, type:'FIX' }]
const rwyByApt = new Map(); // ICAO -> [{ n1, la1, lo1, n2, la2, lo2, w }] (runway ends + width m)
const state = { root: null, loaded: false, count: 0 };
const comByApt = new Map(); // ICAO -> { freq, label, prio } (best ATC/CTAF frequency)
const ilsApts = new Set(); // ICAOs that have an ILS/LOC approach (for NRST "ILS")
const awyCells = new Map(); // "ilat,ilon" (segment midpoint) -> [{ la1, lo1, la2, lo2, name }]
const state = { root: null, loaded: false, count: 0, awy: 0 };
function add(id, lat, lon, type) {
function add(id, lat, lon, type, name) {
if (!id || !isFinite(lat) || !isFinite(lon)) return;
const key = id.toUpperCase();
if (!index.has(key)) index.set(key, { id: key, lat, lon, type });
if (!index.has(key)) index.set(key, { id: key, lat, lon, type, name: name || '' });
}
function pushFix(f) {
@@ -90,10 +93,15 @@ async function parseNav(file) {
if (!t || t === '99' || /^[IA]\b/.test(t) || /Version/.test(t)) continue;
const p = t.split(/\s+/);
const code = parseInt(p[0], 10);
if (code === 4 || code === 5) { // ILS/LOC localizer → airport has an ILS
const ic = (p[8] || '').toUpperCase();
if (ic && ic !== 'ENRT') ilsApts.add(ic);
continue;
}
if (code !== 2 && code !== 3) continue; // 2 = NDB, 3 = VOR/DME
const lat = parseFloat(p[1]), lon = parseFloat(p[2]), id = p[7];
const type = code === 2 ? 'NDB' : 'VOR';
add(id, lat, lon, type);
add(id, lat, lon, type, p.slice(10).join(' '));
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.
@@ -110,7 +118,7 @@ async function parseAirports(file) {
let icao = null, name = '', elev = 0, placed = false;
const place = (lat, lon) => {
if (!isFinite(lat) || !isFinite(lon)) return;
add(icao, lat, lon, 'APT');
add(icao, lat, lon, 'APT', name);
airports.push({ id: icao.toUpperCase(), lat, lon, name, elev });
placed = true;
};
@@ -128,10 +136,44 @@ async function parseAirports(file) {
}
} else if (!placed && icao && (code === 101 || code === 102)) { // water/heli pad
place(parseFloat(p[code === 101 ? 4 : 5]), parseFloat(p[code === 101 ? 5 : 6]));
} else if (icao && ((code >= 50 && code <= 56) || (code >= 1050 && code <= 1056))) {
// ATC / CTAF frequencies. Old codes 50-56, new 1050-1056. Freq is kHz
// (>100000) or MHz×100. Keep the most useful one (TWR > UNICOM > ATIS …).
const c = code > 1000 ? code - 1000 : code;
const raw = parseInt(p[1], 10);
if (isFinite(raw) && raw > 0) {
const mhz = raw > 100000 ? raw / 1000 : raw / 100;
const meta = { 54: ['TOWER', 5], 51: ['UNICOM', 4], 50: ['ATIS', 3], 53: ['GROUND', 2], 55: ['APP', 1], 56: ['DEP', 1], 52: ['CLNC', 1] }[c] || ['COM', 0];
const key = icao.toUpperCase(), prev = comByApt.get(key);
if (!prev || meta[1] > prev.prio) comByApt.set(key, { freq: mhz, label: meta[0], prio: meta[1] });
}
}
}
}
// Airways (earth_awy.dat): each row is a segment between two named waypoints.
// We resolve both endpoints to coordinates via the fix/navaid index (so this
// must run AFTER parseFixes/parseNav) and bucket segments by their midpoint
// cell for fast bbox queries — exactly like fixes.
async function parseAirways(file) {
if (!fs.existsSync(file)) return;
const rl = readline.createInterface({ input: fs.createReadStream(file), crlfDelay: Infinity });
for await (const line of rl) {
const t = line.trim();
if (!t || t === '99' || /^[IA]\b/.test(t) || /Version/.test(t)) continue;
const p = t.split(/\s+/);
if (p.length < 10) continue;
const a = index.get((p[0] || '').toUpperCase());
const b = index.get((p[3] || '').toUpperCase());
if (!a || !b) continue; // endpoint not in our database
const name = p[p.length - 1];
const k = `${Math.floor((a.lat + b.lat) / 2)},${Math.floor((a.lon + b.lon) / 2)}`;
let arr = awyCells.get(k); if (!arr) { arr = []; awyCells.set(k, arr); }
arr.push({ la1: a.lat, lo1: a.lon, la2: b.lat, lo2: b.lon, name });
state.awy++;
}
}
export async function loadNavData() {
const root = findRoot();
state.root = root;
@@ -147,6 +189,10 @@ export async function loadNavData() {
try {
await parseFixes(pick('earth_fix.dat'));
await parseNav(pick('earth_nav.dat'));
// airways need the fix/navaid index above; parse in the background.
parseAirways(pick('earth_awy.dat'))
.then(() => console.log(`navdata: airways done (${state.awy} segments)`))
.catch((e) => console.log('navdata: airway parse skipped:', e.message));
// apt.dat is large; parse the global airports file in the background.
parseAirports(path.join(root, 'Global Scenery', 'Global Airports', 'Earth nav data', 'apt.dat'))
.then(() => { state.count = index.size; console.log(`navdata: airports done (${index.size} total entries)`); })
@@ -178,13 +224,25 @@ export function search(q, limit = 20) {
// NEAREST: closest airports (default) or navaids to a point, with range/bearing.
export function nearest(lat, lon, { count = 15, type = 'apt' } = {}) {
if (!isFinite(lat) || !isFinite(lon)) return [];
const src = (type === 'vor' || type === 'ndb' || type === 'nav') ? navaids : airports;
const isApt = !(type === 'vor' || type === 'ndb' || type === 'nav');
const src = isApt ? airports : navaids;
return src
.filter((f) => (type === 'vor' || type === 'ndb') ? f.type.toLowerCase() === type : true)
.map((f) => ({ ...f, dist: distNm(lat, lon, f.lat, f.lon), brg: Math.round(bearingDeg(lat, lon, f.lat, f.lon)) }))
.sort((a, b) => a.dist - b.dist)
.slice(0, count)
.map((f) => ({ ...f, dist: +f.dist.toFixed(1) }));
.map((f) => {
const o = { ...f, dist: +f.dist.toFixed(1) };
if (isApt) { // runway length, COM freq, approach type
const rs = rwyByApt.get(f.id);
let ft = 0;
if (rs) for (const r of rs) ft = Math.max(ft, distNm(r.la1, r.lo1, r.la2, r.lo2) * 6076.12);
o.rwyFt = Math.round(ft);
o.com = comByApt.get(f.id) || null;
o.app = ilsApts.has(f.id) ? 'ILS' : 'VFR';
}
return o;
});
}
// BBOX: every feature inside a lat/lon window, for the moving map to draw.
@@ -205,6 +263,22 @@ export function bbox(s, w, n, e, types = ['apt', 'vor', 'ndb'], limit = 800) {
return out;
}
// BBOX airways: every segment touching a lat/lon window (scan the midpoint
// cells overlapping the box, ±1 to catch segments crossing the edge).
export function airwaysBbox(s, w, n, e, limit = 500) {
const out = [];
const inB = (la, lo) => la >= s && la <= n && lo >= w && lo <= e;
for (let la = Math.floor(s) - 1; la <= Math.floor(n) + 1; la++)
for (let lo = Math.floor(w) - 1; lo <= Math.floor(e) + 1; lo++) {
const arr = awyCells.get(`${la},${lo}`);
if (!arr) continue;
for (const sg of arr) {
if (inB(sg.la1, sg.lo1) || inB(sg.la2, sg.lo2)) { out.push(sg); if (out.length >= limit) return out; }
}
}
return out;
}
// Runways of every airport within radiusNm — for the PFD's synthetic-vision view.
export function runwaysNear(lat, lon, radiusNm = 12) {
if (!isFinite(lat) || !isFinite(lon)) return [];
+8 -3
View File
@@ -121,6 +121,10 @@ export function procedureLegs(icao, type, name, trans) {
const out = [];
const seen = new Set();
// For approaches, the runway threshold is the Missed-Approach Point: legs after
// it are the missed-approach segment. We tag (rather than drop) them so the
// FMS can hold them un-sequenced and "Activate Missed Approach" can fly them.
let inMissed = false;
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
@@ -133,9 +137,10 @@ export function procedureLegs(icao, type, name, trans) {
}
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;
const wp = { id: leg.fix, lat: pt.lat, lon: pt.lon, type: isRwy ? 'APT' : 'WPT', alt: leg.alt };
if (TYPE === 'APPCH') wp.seg = inMissed ? 'missed' : 'approach';
out.push(wp);
if (TYPE === 'APPCH' && isRwy) inMissed = true; // everything past the runway = missed
}
return out;
}
+14 -2
View File
@@ -1,7 +1,7 @@
// Minimal service worker: caches the app shell so the cockpit launches fast and
// survives brief network blips. Live data (the bridge WebSocket, /api, and map
// tiles) is never cached — only same-origin GET app assets.
const CACHE = 'g1000-shell-v1';
const CACHE = 'g1000-shell-v2';
self.addEventListener('install', () => self.skipWaiting());
@@ -18,7 +18,19 @@ self.addEventListener('fetch', (e) => {
if (e.request.method !== 'GET' || url.origin !== location.origin) return;
if (url.pathname.startsWith('/api') || url.pathname === '/ws') return;
// Stale-while-revalidate: serve cache fast, refresh in the background.
// The HTML entry is NETWORK-FIRST: a reload always gets the latest build (and
// thus the latest hashed assets). Falls back to cache only when offline.
const isDoc = e.request.mode === 'navigate' || url.pathname === '/' || url.pathname.endsWith('.html');
if (isDoc) {
e.respondWith(
fetch(e.request)
.then((res) => { caches.open(CACHE).then((c) => c.put(e.request, res.clone())); return res; })
.catch(() => caches.match(e.request).then((c) => c || caches.match('/')))
);
return;
}
// Hashed assets are immutable → stale-while-revalidate (fast + self-healing).
e.respondWith(
caches.open(CACHE).then(async (cache) => {
const cached = await cache.match(e.request);
+86 -21
View File
@@ -1,4 +1,4 @@
import React, { useState } from 'react';
import React, { useState, useEffect } from 'react';
import { useXplane } from './api/useXplane.js';
import PFD from './components/PFD.jsx';
import AutopilotPanel from './components/AutopilotPanel.jsx';
@@ -9,6 +9,8 @@ import VFR from './components/VFR.jsx';
import Bezel from './components/Bezel.jsx';
import DirectTo from './components/DirectTo.jsx';
import Proc from './components/Proc.jsx';
import FplPage from './components/FplPage.jsx';
import AudioPanel from './components/AudioPanel.jsx';
// Compact line icons for the nav rail (stroke = currentColor).
const ICONS = {
@@ -18,6 +20,7 @@ const ICONS = {
fms: 'M4 6h14M4 11h14M4 16h9',
ap: 'M11 4a7 7 0 100 14 7 7 0 000-14zM11 4v3M11 15v3M4 11h3M15 11h3',
vfr: 'M11 4a7 7 0 100 14 7 7 0 000-14zM11 11l4.5-3',
audio: 'M11 4a6 6 0 00-6 6v5M17 15v-5a6 6 0 00-6-6M4 14h2.5v4.5H4zM15.5 14H18v4.5h-2.5z',
};
function Icon({ name }) {
return (
@@ -36,6 +39,7 @@ const TABS = [
{ id: 'fms', label: 'FMS' },
{ id: 'vfr', label: 'VFR' },
{ id: 'ap', label: 'Autopilot' },
{ id: 'audio', label: 'Audio' },
];
export default function App() {
@@ -45,27 +49,60 @@ export default function App() {
const [navWide, setNavWide] = useState(() => localStorage.getItem('navWide') === '1');
const go = (id) => { setTab(id); history.replaceState(null, '', `#${id}`); };
const toggleNav = () => setNavWide((w) => { localStorage.setItem('navWide', w ? '0' : '1'); return !w; });
// Knob interaction: 'arrows' (visible ˄‹›˅, touch-friendly) or 'zones' (click
// the knob face). Settable in the settings panel, remembered.
const [knobMode, setKnobMode] = useState(() => localStorage.getItem('knobMode') || 'arrows');
const [settings, setSettings] = useState(false);
const setKnob = (m) => { localStorage.setItem('knobMode', m); setKnobMode(m); };
// Synthetic-terrain (3D) vs. classic blue/brown attitude — toggled by the
// PFD → SYN TERR softkey, exactly like the real XPLANE 1000.
const [svt3d, setSvt3d] = useState(true);
const [svt3d, setSvt3d] = useState(false);
// The PFD INSET map (bottom-left) is off by default and toggled by its softkey.
const [inset, setInset] = useState(false);
// INSET map options (base layer + declutter), set from the INSET submenu.
const [insetMode, setInsetMode] = useState({ base: 'topo', dcltr: 0 });
// The NRST (nearest airports/navaids) window, toggled by the PFD NRST softkey.
const [nrst, setNrst] = useState(false);
// The TMR/REF (timer / references) window, toggled by the PFD TMR/REF softkey.
const [tmr, setTmr] = useState(false);
// MFD map mode (base layer), switched via the Map-Opt softkeys.
// Like the real G1000, only ONE window is open at a time. A single string
// holds the open one (nrst / tmr / dme / alerts / fpl / dto / proc); toggling
// the same softkey closes it, opening another replaces it.
const [win, setWin] = useState(null);
const toggleWin = (id) => setWin((w) => (w === id ? null : id));
const nrst = win === 'nrst', tmr = win === 'tmr', dme = win === 'dme', alerts = win === 'alerts';
const fpl = win === 'fpl', dto = win === 'dto', proc = win === 'proc';
// MFD map mode (base layer + overlays), switched via the Map-Opt softkeys.
const [mapMode, setMapMode] = useState({ base: 'topo' });
// Direct-To (D→) dialog — opened from the bezel on either GDU.
const [dto, setDto] = useState(false);
// PROC (procedures: SID/STAR/approach) dialog — opened from the bezel.
const [proc, setProc] = useState(false);
// Altimeter barometric units (false = inHg, true = hectopascal) — PFD ALT UNIT softkey.
const [baroHpa, setBaroHpa] = useState(false);
// Barometric minimums (set in TMR/REF) — shown on the PFD altimeter as BARO MIN.
const [minimums, setMinimums] = useState({ on: false, ft: 500 });
// OBS (omni-bearing select) mode — suspends GPS sequencing, course set by CRS knob.
const [obs, setObs] = useState(false);
// MFD page group (MAP / FPL / NRST) — selected by the FMS knob, like the real G1000.
const MFD_PAGES = ['map', 'fpl', 'nrst'];
const [mfdPage, setMfdPage] = useState('map');
const cycleMfd = (dir = 1) => setMfdPage((p) => MFD_PAGES[(MFD_PAGES.indexOf(p) + dir + MFD_PAGES.length) % MFD_PAGES.length]);
// G1000 UI-state sync (Sim → App): follow the in-sim G1000 when the FlyWithLua
// companion publishes its state. No-ops until then, so local control still works.
const uiInset = xp.values.uiInset, uiPage = xp.values.uiMfdPage;
useEffect(() => { if (uiInset === 0 || uiInset === 1) setInset(!!uiInset); }, [uiInset]);
useEffect(() => { if (typeof uiPage === 'number' && MFD_PAGES[uiPage]) setMfdPage(MFD_PAGES[uiPage]); }, [uiPage]);
const connKind = xp.xpConnected ? 'ok' : xp.connected ? 'warn' : 'bad';
const connText = xp.xpConnected ? 'X-PLANE' : xp.connected ? 'NO SIM' : 'OFFLINE';
// G1000 side-window dialogs — rendered inside the bezel display so they sit in
// the display's lower-right (like the real unit), not over the whole app.
const dialogs = (
<>
{dto && <DirectTo xp={xp} onClose={() => setWin(null)} />}
{proc && <Proc xp={xp} onClose={() => setWin(null)} />}
{fpl && (
<div className="gwin-backdrop" onClick={() => setWin(null)}>
<div onClick={(e) => e.stopPropagation()}><FplPage xp={xp} onClose={() => setWin(null)} /></div>
</div>
)}
</>
);
return (
<div className={`app ${navWide ? 'nav-wide' : 'nav-narrow'}`}>
<aside className="sidebar">
@@ -82,6 +119,13 @@ export default function App() {
</button>
))}
</nav>
<button className="snav-i sb-gear" onClick={() => setSettings(true)} title="Einstellungen">
<svg className="snav-ic" viewBox="0 0 22 22" width="22" height="22" fill="none" stroke="currentColor" strokeWidth="1.6" strokeLinecap="round" strokeLinejoin="round">
<circle cx="11" cy="11" r="3.2" />
<path d="M11 2.5v2M11 17.5v2M2.5 11h2M17.5 11h2M5 5l1.4 1.4M15.6 15.6L17 17M17 5l-1.4 1.4M6.4 15.6L5 17" />
</svg>
<span className="snav-lbl">Einstellungen</span>
</button>
<div className={`sb-conn ${connKind}`} title={connText}>
<span className="dot" />
<span className="snav-lbl">{connText}</span>
@@ -90,26 +134,47 @@ export default function App() {
<main className="screen">
{tab === 'pfd' && (
<Bezel variant="pfd" xp={xp} svt3d={svt3d} onToggleSvt={() => setSvt3d((v) => !v)}
<Bezel variant="pfd" xp={xp} knobMode={knobMode} svt3d={svt3d} onToggleSvt={() => setSvt3d((v) => !v)}
inset={inset} onSetInset={setInset} insetMode={insetMode} onInsetMode={setInsetMode}
nrst={nrst} onToggleNrst={() => setNrst((v) => !v)} onDirect={() => setDto(true)}
tmr={tmr} onToggleTmr={() => setTmr((v) => !v)} onProc={() => setProc(true)}>
<PFD values={xp.values} svt={svt3d} inset={inset} insetMode={insetMode} nrst={nrst} onCloseNrst={() => setNrst(false)}
tmr={tmr} onCloseTmr={() => setTmr(false)} flightPlan={xp.flightPlan} fp={xp.fp} />
nrst={nrst} onToggleNrst={() => toggleWin('nrst')} onDirect={() => toggleWin('dto')}
tmr={tmr} onToggleTmr={() => toggleWin('tmr')} dme={dme} onToggleDme={() => toggleWin('dme')}
alerts={alerts} onToggleAlerts={() => toggleWin('alerts')} onProc={() => toggleWin('proc')} onFpl={() => toggleWin('fpl')} onClr={() => setWin(null)}
altHpa={baroHpa} onAltUnit={setBaroHpa} obs={obs} onObs={() => setObs((v) => !v)}>
<PFD values={xp.values} command={xp.command} connected={xp.xpConnected} svt={svt3d} inset={inset} insetMode={insetMode} nrst={nrst} onCloseNrst={() => setWin(null)}
tmr={tmr} onCloseTmr={() => setWin(null)} dme={dme} onCloseDme={() => setWin(null)}
alerts={alerts} onCloseAlerts={() => setWin(null)} baroHpa={baroHpa} obs={obs}
minimums={minimums} onMinimums={setMinimums} flightPlan={xp.flightPlan} fp={xp.fp} />
{dialogs}
</Bezel>
)}
{tab === 'mfd' && (
<Bezel variant="mfd" xp={xp} mapMode={mapMode} onMapMode={setMapMode} onDirect={() => setDto(true)} onProc={() => setProc(true)}>
<MFD values={xp.values} flightPlan={xp.flightPlan} fp={xp.fp} mapMode={mapMode} />
<Bezel variant="mfd" xp={xp} knobMode={knobMode} mapMode={mapMode} onMapMode={setMapMode} onDirect={() => toggleWin('dto')} onProc={() => toggleWin('proc')} onFms={cycleMfd} onFpl={() => setMfdPage('fpl')} onClr={() => setWin(null)}>
<MFD values={xp.values} flightPlan={xp.flightPlan} fp={xp.fp} mapMode={mapMode} page={mfdPage} onCycle={cycleMfd} xp={xp} />
{dialogs}
</Bezel>
)}
{tab === 'map' && <MapView values={xp.values} flightPlan={xp.flightPlan} fp={xp.fp} />}
{tab === 'fms' && <CDU xp={xp} />}
{tab === 'vfr' && <VFR values={xp.values} />}
{tab === 'vfr' && <VFR xp={xp} />}
{tab === 'ap' && <AutopilotPanel xp={xp} />}
{tab === 'audio' && <AudioPanel xp={xp} />}
</main>
{dto && <DirectTo xp={xp} onClose={() => setDto(false)} />}
{proc && <Proc xp={xp} onClose={() => setProc(false)} />}
{settings && (
<div className="dlg-backdrop" onClick={() => setSettings(false)}>
<div className="dlg" onClick={(e) => e.stopPropagation()} style={{ minWidth: 360 }}>
<div className="dlg-head">EINSTELLUNGEN</div>
<div style={{ padding: 14 }}>
<div className="set-lbl">Knopf-Bedienung</div>
<div className="set-opt">
<button className={`fbtn ${knobMode === 'arrows' ? 'add' : ''}`} onClick={() => setKnob('arrows')}>Pfeiltasten ˄˅</button>
<button className={`fbtn ${knobMode === 'zones' ? 'add' : ''}`} onClick={() => setKnob('zones')}>Klickzonen am Knopf</button>
</div>
<div className="set-hint">Pfeiltasten sind touch-freundlich. Klickzonen: oben/unten = grob, links/rechts = fein, Mitte = PUSH.</div>
</div>
<div className="dlg-actions"><button className="fbtn" onClick={() => setSettings(false)}>Schließen</button></div>
</div>
</div>
)}
</div>
);
}
+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;
}
+28 -1
View File
@@ -6,6 +6,7 @@ import { useEffect, useRef, useState, useCallback } from 'react';
export function useXplane() {
const [values, setValues] = useState({});
const [flightPlan, setFlightPlan] = useState({ name: 'ACTIVE', waypoints: [] });
const [terrain, setTerrain] = useState(null); // elevation grid for terrain awareness
const [exportMsg, setExportMsg] = useState(null);
const [connected, setConnected] = useState(false); // socket to bridge
const [xpConnected, setXpConnected] = useState(false); // bridge <-> X-Plane
@@ -37,6 +38,7 @@ export function useXplane() {
}
else if (msg.type === 'status') setXpConnected(!!msg.xpConnected);
else if (msg.type === 'flightplan') setFlightPlan(msg.data);
else if (msg.type === 'terrain') setTerrain(msg.data);
else if (msg.type === 'fp_export_result') setExportMsg(msg);
};
ws.onclose = () => {
@@ -68,9 +70,16 @@ export function useXplane() {
clear: () => send({ type: 'fp_clear' }),
set: (plan) => send({ type: 'fp_set', plan }),
export: (name) => send({ type: 'fp_export', name }),
load: (name) => send({ type: 'fp_load', name }),
};
return { values, flightPlan, exportMsg, connected, xpConnected, command, setDataref, fp };
return { values, flightPlan, terrain, exportMsg, connected, xpConnected, command, setDataref, fp };
}
// List saved .fms flight plans (X-Plane's Output/FMS plans) via the bridge.
export async function fmsList() {
try { const r = await fetch('/api/fms/list'); return r.ok ? r.json() : []; }
catch { return []; }
}
// Search X-Plane's nav database (waypoints/VOR/NDB/airports) via the bridge.
@@ -86,3 +95,21 @@ export async function navSearch(q) {
// Convenience: read a numeric value with a fallback.
export const num = (v, d = 0) => (typeof v === 'number' && isFinite(v) ? v : d);
const v0 = (x) => (Array.isArray(x) ? num(x[0]) : num(x));
// System alerts/annunciations derived from live datarefs — drives the PFD
// CAUTION softkey + the ALERTS window. Each: { t: text, warn: bool (red vs amber) }.
export function systemAlerts(V = {}) {
const out = [];
const rpm = v0(V.engRpm);
const running = rpm > 400;
const oilP = v0(V.oilPress);
const oilT = v0(V.oilTemp); const oilF = oilT > 150 ? oilT : oilT * 9 / 5 + 32;
const volts = v0(V.volts);
const fuelGal = (Array.isArray(V.fuelQty) ? V.fuelQty.reduce((a, b) => a + num(b), 0) : num(V.fuelQty)) / 2.72;
if (running && oilP < 20) out.push({ t: 'OIL PRESSURE', warn: true });
if (oilF > 245) out.push({ t: 'OIL TEMP HIGH', warn: true });
if (Array.isArray(V.fuelQty) && fuelGal < 5) out.push({ t: 'FUEL LOW TOTAL', warn: fuelGal < 2.5 });
if (volts > 1 && volts < 24.5) out.push({ t: 'LOW VOLTS', warn: false });
return out;
}
+93
View File
@@ -0,0 +1,93 @@
import React, { useState } from 'react';
// X1000 Audio Panel (Manual S.91). Selects which radios are heard, which COM is
// used to transmit (MIC), marker/DME/ADF Morse audio, intercom, and the Display
// Backup (reversionary) key. Selections are local state with authentic lit keys.
//
// COM MIC is single-select (one transmit radio); the receive/audio keys and the
// Morse keys toggle independently — exactly like the real unit.
export default function AudioPanel({ xp }) {
const [mic, setMic] = useState('com1'); // transmit radio: com1 | com2 | tel
const [recv, setRecv] = useState({ com1: true }); // receive/audio selections
const [hiSens, setHiSens] = useState(false);
const [crew, setCrew] = useState('pilot');
const [vol, setVol] = useState(60);
const r = (k) => !!recv[k];
const toggle = (k) => setRecv((s) => ({ ...s, [k]: !s[k] }));
// a single audio key: lit green for MIC (transmit), cyan for receive/Morse
const Key = ({ k, label, sub, on, kind = 'recv', onClick }) => (
<button className={`apk ${kind} ${on ? 'on' : ''}`} onClick={onClick}>
<span className="apk-l">{label}</span>{sub && <span className="apk-s">{sub}</span>}
</button>
);
return (
<div className="audio-panel">
<div className="apnl">
<div className="apnl-title">AUDIO PANEL</div>
<div className="apnl-grp">
<div className="apnl-h">COM</div>
<div className="apnl-row">
<Key label="COM1 MIC" kind="mic" on={mic === 'com1'} onClick={() => setMic('com1')} />
<Key label="COM1" on={r('com1')} onClick={() => toggle('com1')} />
</div>
<div className="apnl-row">
<Key label="COM2 MIC" kind="mic" on={mic === 'com2'} onClick={() => setMic('com2')} />
<Key label="COM2" on={r('com2')} onClick={() => toggle('com2')} />
</div>
<div className="apnl-row">
<Key label="COM 1/2" on={false} onClick={() => setMic((m) => (m === 'com1' ? 'com2' : 'com1'))} />
<Key label="TEL" kind="mic" on={mic === 'tel'} onClick={() => setMic('tel')} />
</div>
</div>
<div className="apnl-grp">
<div className="apnl-h">CABIN / SPEAKER</div>
<div className="apnl-row">
<Key label="PA" on={r('pa')} onClick={() => toggle('pa')} />
<Key label="SPKR" on={r('spkr')} onClick={() => toggle('spkr')} />
</div>
<div className="apnl-row">
<Key label="MKR / MUTE" on={r('mkr')} onClick={() => toggle('mkr')} />
<Key label="HI SENS" on={hiSens} onClick={() => setHiSens((v) => !v)} />
</div>
</div>
<div className="apnl-grp">
<div className="apnl-h">NAV</div>
<div className="apnl-row">
<Key label="DME" on={r('dme')} onClick={() => toggle('dme')} />
<Key label="NAV1" on={r('nav1')} onClick={() => toggle('nav1')} />
</div>
<div className="apnl-row">
<Key label="ADF" on={r('adf')} onClick={() => toggle('adf')} />
<Key label="NAV2" on={r('nav2')} onClick={() => toggle('nav2')} />
</div>
<div className="apnl-row">
<Key label="AUX" on={r('aux')} onClick={() => toggle('aux')} />
<Key label="MAN SQ" on={r('msq')} onClick={() => toggle('msq')} />
</div>
</div>
<div className="apnl-grp">
<div className="apnl-h">CREW · ICS</div>
<div className="apnl-row">
<Key label="PILOT" kind="mic" on={crew === 'pilot'} onClick={() => setCrew('pilot')} />
<Key label="COPLT" kind="mic" on={crew === 'copilot'} onClick={() => setCrew('copilot')} />
</div>
<div className="apnl-vol">
<span>PILOT INTERCOM VOL</span>
<input type="range" min="0" max="100" value={vol} onChange={(e) => setVol(+e.target.value)} />
<b>{vol}</b>
</div>
</div>
<button className="apnl-backup" onClick={() => xp && xp.command && xp.command('mfd_softkey1')} title="Display Backup (reversionary)">
DISPLAY BACKUP
</button>
</div>
</div>
);
}
+23 -20
View File
@@ -6,19 +6,22 @@ import { num } from '../api/useXplane.js';
// of lit mode keys, and selectors (HDG / ALT / VS / IAS) with knob steppers.
// Buttons fire X-Plane's own autopilot commands; the sim stays the source of truth.
const AP_BITS = {
fd: 1 << 0, hdg: 1 << 1, vs: 1 << 4, flc: 1 << 6,
nav: 1 << 8, apr: 1 << 9, vnav: 1 << 11, altHold: 1 << 14, bc: 1 << 18,
};
const on = (s, b) => (num(s) & b) !== 0;
// Per-mode autopilot status (0 off · 1 armed · 2 active) — the same reliable
// datarefs the PFD AFCS bar uses. The old approach decoded a single
// autopilot_state bitfield, whose bit positions don't reliably match X-Plane
// (e.g. bit 0 is auto-throttle, not the flight director), so several mode keys
// never lit up. Reading the dedicated *_status datarefs lights every key.
export default function AutopilotPanel({ xp }) {
const { values: V, command, setDataref } = xp;
const s = num(V.apState);
const eng = num(V.apEngaged) > 0;
const lit = (k) => num(V[k]) > 0; // armed or active
const apMode = num(V.apMode); // 0 off · 1 FD · 2 AP
const eng = num(V.apEngaged) > 0 || apMode >= 2;
const fdOn = apMode >= 1 || eng;
const lateral = on(s, AP_BITS.apr) ? 'APR' : on(s, AP_BITS.nav) ? 'NAV' : on(s, AP_BITS.hdg) ? 'HDG' : 'ROL';
const vertical = on(s, AP_BITS.flc) ? 'FLC' : on(s, AP_BITS.vs) ? 'VS' : on(s, AP_BITS.vnav) ? 'VNV' : on(s, AP_BITS.altHold) ? 'ALT' : 'PIT';
const lateral = lit('aprStatus') ? 'APR' : lit('gpssStatus') ? 'GPS' : lit('navStatus') ? 'VOR'
: lit('bcStatus') ? 'BC' : lit('hdgStatus') ? 'HDG' : 'ROL';
const vertical = lit('gsStatus') ? 'GS' : lit('flcStatus') ? 'FLC' : lit('vsStatus') ? 'VS'
: lit('vnavStatus') ? 'VNV' : lit('altStatus') ? 'ALT' : 'PIT';
const Key = ({ label, cmd, active }) => (
<button className={`apk ${active ? 'on' : ''}`} onClick={() => command(cmd)}>{label}</button>
@@ -42,7 +45,7 @@ export default function AutopilotPanel({ xp }) {
{/* annunciator bar */}
<div className="afcs-ann">
<span className={`ann ${eng ? 'on' : ''}`}>AP</span>
<span className={`ann ${on(s, AP_BITS.fd) ? 'on' : ''}`}>FD</span>
<span className={`ann ${fdOn ? 'on' : ''}`}>FD</span>
<span className="ann-sep" />
<span className="ann mode on">{lateral}</span>
<span className="ann-gap" />
@@ -53,15 +56,15 @@ export default function AutopilotPanel({ xp }) {
{/* mode keys */}
<div className="afcs-keys">
<Key label="AP" cmd="apToggle" active={eng} />
<Key label="FD" cmd="fdToggle" active={on(s, AP_BITS.fd)} />
<Key label="HDG" cmd="hdg" active={on(s, AP_BITS.hdg)} />
<Key label="NAV" cmd="nav" active={on(s, AP_BITS.nav)} />
<Key label="APR" cmd="apr" active={on(s, AP_BITS.apr)} />
<Key label="BC" cmd="backCourse" active={on(s, AP_BITS.bc)} />
<Key label="ALT" cmd="altHold" active={on(s, AP_BITS.altHold)} />
<Key label="VS" cmd="vs" active={on(s, AP_BITS.vs)} />
<Key label="VNV" cmd="vnav" active={on(s, AP_BITS.vnav)} />
<Key label="FLC" cmd="flc" active={on(s, AP_BITS.flc)} />
<Key label="FD" cmd="fdToggle" active={fdOn} />
<Key label="HDG" cmd="hdg" active={lit('hdgStatus')} />
<Key label="NAV" cmd="nav" active={lit('navStatus') || lit('gpssStatus')} />
<Key label="APR" cmd="apr" active={lit('aprStatus')} />
<Key label="BC" cmd="backCourse" active={lit('bcStatus')} />
<Key label="ALT" cmd="altHold" active={lit('altStatus')} />
<Key label="VS" cmd="vs" active={lit('vsStatus')} />
<Key label="VNV" cmd="vnav" active={lit('vnavStatus')} />
<Key label="FLC" cmd="flc" active={lit('flcStatus')} />
</div>
{/* selectors */}
+108 -52
View File
@@ -1,5 +1,5 @@
import React, { useState } from 'react';
import { num } from '../api/useXplane.js';
import { num, systemAlerts } from '../api/useXplane.js';
// The physical GDU bezel of X-Plane's "XPLANE 1000" (its G1000 clone):
// title bar, knob columns, the 12 softkeys along the bottom — and, on the MFD,
@@ -14,7 +14,9 @@ import { num } from '../api/useXplane.js';
// SYN TERR toggles the 3D synthetic-vision terrain on/off.
const PFD_MENU = {
root: ['', 'INSET', '', 'PFD', '', 'CDI', 'DME', 'XPDR', 'IDENT', 'TMR/REF', 'NRST', 'CAUTION'],
pfd: ['PATHWAY', 'SYN TERR', 'HRZN HDG', 'APTSIGNS', '', '', '', '', '', '', '', 'BACK'],
pfd: ['PATHWAY', 'SYN TERR', 'HRZN HDG', 'APTSIGNS', 'ALT UNIT', '', '', '', '', '', '', 'BACK'],
// ALT UNIT submenu: barometric pressure units (inHg / hectopascal), like the manual.
altunit: ['IN', 'HPA', '', '', '', '', '', '', '', '', '', 'BACK'],
// XPDR submenu: standby/on/alt modes, VFR (1200), CODE entry, IDENT.
xpdr: ['STBY', 'ON', 'ALT', 'VFR', '', 'CODE', 'IDENT', '', '', '', '', 'BACK'],
// CODE entry turns the softkeys into the octal squawk keypad (digits 07).
@@ -26,25 +28,29 @@ const PFD_MENU = {
// page; TOPO/TERRAIN/OSM switch the base map; BACK returns. (OSM is our tuned
// extra layer in an otherwise-empty slot.)
const MFD_MENU = {
root: ['SYSTEM', 'MAP', '', '', '', '', '', '', '', 'DCLTR', '', ''],
mapopt: ['TRAFFIC', 'PROFILE', 'TOPO', 'TERRAIN', 'AIRWAYS', '', 'NEXRAD', 'OSM', '', '', 'BACK', ''],
system: ['DEC FUEL', 'INC FUEL', 'RST FUEL', '', '', '', '', '', '', '', 'BACK', ''],
root: ['ENGINE', 'MAP', '', '', '', '', '', '', '', 'DCLTR', '', ''],
mapopt: ['TRAFFIC', 'PROFILE', 'TOPO', 'TERRAIN', 'AIRWAYS', 'AIRSPACE', 'NEXRAD', 'OSM', '', '', 'BACK', ''],
engine: ['DEC FUEL', 'INC FUEL', 'RST FUEL', '', '', '', '', '', '', '', 'BACK', ''],
};
// autopilot_state bitfield (best-effort; tweak per aircraft)
const AP_BITS = { fd: 1 << 0, hdg: 1 << 1, vs: 1 << 4, flc: 1 << 6, nav: 1 << 8, apr: 1 << 9, vnav: 1 << 11, altHold: 1 << 14, bc: 1 << 18 };
export default function Bezel({ variant = 'pfd', xp, svt3d, onToggleSvt, inset, onSetInset, insetMode, onInsetMode, nrst, onToggleNrst, tmr, onToggleTmr, onDirect, onProc, mapMode, onMapMode, children }) {
export default function Bezel({ variant = 'pfd', xp, svt3d, onToggleSvt, inset, onSetInset, insetMode, onInsetMode, nrst, onToggleNrst, tmr, onToggleTmr, dme, onToggleDme, alerts, onToggleAlerts, onDirect, onProc, onFpl, onClr, onFms, mapMode, onMapMode, altHpa, onAltUnit, obs, onObs, knobMode = 'arrows', children }) {
const u = variant === 'mfd' ? 'mfd' : 'pfd'; // command prefix
const fire = (suffix) => xp && xp.command(`${u}_${suffix}`);
const [page, setPage] = useState('root'); // softkey menu page
const [squawk, setSquawk] = useState(''); // XPDR code being typed
const menu = variant === 'mfd' ? MFD_MENU : PFD_MENU;
const keys = menu[page] || menu.root;
let keys = menu[page] || menu.root;
// OBS appears in the PFD root only when a flight-plan leg is active (like the real unit)
const hasLeg = (xp?.flightPlan?.waypoints?.length || 0) >= 2;
if (variant !== 'mfd' && page === 'root' && hasLeg) { keys = keys.slice(); keys[4] = 'OBS'; }
const setBase = (b) => onMapMode && onMapMode((m) => ({ ...m, base: m.base === b ? 'dark' : b }));
const xpdrMode = num(xp?.values?.xpdrMode);
const setMode = (m) => xp && xp.setDataref('xpdrMode', m);
const hasAlerts = systemAlerts(xp?.values).length > 0; // lights the CAUTION key
const typeDigit = (d) => {
const next = (squawk + d).slice(-4);
@@ -57,28 +63,42 @@ export default function Bezel({ variant = 'pfd', xp, svt3d, onToggleSvt, inset,
const onSoftkey = (i, label) => {
fire(`softkey${i + 1}`); // mirror to the in-sim G1000
// declutter cycles through 4 levels (0=all … 3=flight plan only), like the manual
const cycleDcltr = (setter) => setter && setter((m) => ({ ...m, dcltr: (((m.dcltr || 0) + 1) % 4) }));
if (variant === 'mfd') {
if (label === 'MAP') setPage('mapopt');
else if (label === 'SYSTEM') setPage('system');
else if (label === 'ENGINE') setPage('engine');
else if (label === 'BACK') setPage('root');
else if (label === 'TOPO') setBase('topo');
else if (label === 'TERRAIN') setBase('terrain');
else if (label === 'TOPO') setBase('topo'); // relief on/off
else if (label === 'TERRAIN') onMapMode && onMapMode((m) => ({ ...m, terrain: !m.terrain })); // awareness overlay (independent)
else if (label === 'OSM') setBase('osm');
else if (label === 'DCLTR') onMapMode && onMapMode((m) => ({ ...m, dcltr: m.dcltr ? 0 : 1 }));
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(page === 'xpdrcode' ? 'xpdr' : 'root');
else if (label === 'BACK') setPage({ xpdrcode: 'xpdr', altunit: 'pfd' }[page] || 'root');
else if (label === 'SYN TERR') onToggleSvt && onToggleSvt();
else if (label === 'ALT UNIT') setPage('altunit');
else if (label === 'IN') { onAltUnit && onAltUnit(false); setPage('pfd'); }
else if (label === 'HPA') { onAltUnit && onAltUnit(true); setPage('pfd'); }
else if (label === 'INSET') {
if (page === 'root') { onSetInset && onSetInset(true); setPage('inset'); }
else onSetInset && onSetInset(!inset); // toggle from within the submenu
}
else if (label === 'OFF') { onSetInset && onSetInset(false); setPage('root'); }
else if (label === 'DCLTR') onInsetMode && onInsetMode((m) => ({ ...m, dcltr: m.dcltr ? 0 : 1 }));
else if (label === 'TOPO') onInsetMode && onInsetMode((m) => ({ ...m, base: 'topo' }));
else if (label === 'TERRAIN') onInsetMode && onInsetMode((m) => ({ ...m, base: 'terrain' }));
else if (label === 'DCLTR') cycleDcltr(onInsetMode);
else if (label === 'TOPO') onInsetMode && onInsetMode((m) => ({ ...m, base: m.base === 'topo' ? 'dark' : 'topo' }));
else if (label === 'TERRAIN') onInsetMode && onInsetMode((m) => ({ ...m, terrain: !m.terrain }));
else if (label === 'NRST') onToggleNrst && onToggleNrst();
else if (label === 'TMR/REF') onToggleTmr && onToggleTmr();
else if (label === 'DME') onToggleDme && onToggleDme();
else if (label === 'CDI' && xp) { // cycle the HSI/CDI nav source: GPS → NAV1 → NAV2
const cur = Math.round(num(xp.values?.cdiSrc, 2));
xp.setDataref('cdiSrc', (cur + 1) % 3); // 2→0→1→2 = GPS→NAV1(VLOC1)→NAV2(VLOC2)
}
else if (label === 'OBS') onObs && onObs(); // suspend / OBS mode (also fires the sim softkey above)
else if (label === 'CAUTION') onToggleAlerts && onToggleAlerts();
else if (label === 'XPDR') setPage('xpdr');
else if (label === 'STBY') setMode(1);
else if (label === 'ON') setMode(2);
@@ -93,59 +113,72 @@ export default function Bezel({ variant = 'pfd', xp, svt3d, onToggleSvt, inset,
// which softkey is "lit" right now
const isOn = (label) => {
if (variant === 'mfd') return (label === 'TOPO' && mapMode?.base === 'topo')
|| (label === 'TERRAIN' && mapMode?.base === 'terrain') || (label === 'OSM' && mapMode?.base === 'osm')
|| (label === 'DCLTR' && mapMode?.dcltr > 0);
|| (label === 'TERRAIN' && mapMode?.terrain) || (label === 'OSM' && mapMode?.base === 'osm')
|| (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)
|| (label === 'IN' && !altHpa) || (label === 'HPA' && altHpa)
|| (page === 'inset' && label === 'TOPO' && insetMode?.base === 'topo')
|| (page === 'inset' && label === 'TERRAIN' && insetMode?.base === 'terrain')
|| (page === 'inset' && label === 'TERRAIN' && insetMode?.terrain)
|| (label === 'DCLTR' && insetMode?.dcltr > 0);
};
return (
<div className="bezel">
<div className="bezel-knobs left">
<Knob label="NAV" sub="VOL · PUSH ID" fire={fire}
outer={['nav_outer_up', 'nav_outer_down']} inner={['nav_inner_up', 'nav_inner_down']} push="nav12" />
<Knob label="HDG" sub="PUSH HDG SYNC" fire={fire}
<Knob label="NAV" sub="VOL · PUSH ID" fire={fire} mode={knobMode}
outer={['nav_outer_up', 'nav_outer_down']} inner={['nav_inner_up', 'nav_inner_down']} push="nav12"
swap={() => xp && xp.command('nav1Swap')} />
<Knob label="HDG" sub="PUSH HDG SYNC" fire={fire} mode={knobMode}
outer={['hdg_up', 'hdg_down']} push="hdg_sync" />
{variant === 'mfd' && xp && <APController xp={xp} />}
<Knob label="ALT" sub="" big fire={fire}
<Knob label="ALT" sub="" big fire={fire} mode={knobMode}
outer={['alt_outer_up', 'alt_outer_down']} inner={['alt_inner_up', 'alt_inner_down']} />
</div>
<div className="bezel-core">
<div className="bezel-title">XPLANE 1000</div>
<div className="bezel-screen">{children}</div>
{page === 'xpdrcode' && (
<div className="squawk-entry">SQUAWK <b>{squawk.padEnd(4, '_')}</b></div>
)}
<div className="bezel-screen">
<div className="screen-content">{children}</div>
{page === 'xpdrcode' && (
<div className="squawk-entry">SQUAWK <b>{squawk.padEnd(4, '_')}</b></div>
)}
{/* softkey LABELS on the display (lowest line), like the real G1000 */}
<div className="sk-labels">
{keys.map((s, i) => (
<span key={i} className={`skl ${s ? '' : 'empty'} ${s === 'CAUTION' ? 'caution' : ''} ${isOn(s) ? 'on' : ''}`}>{
(s === 'DCLTR' && (mapMode?.dcltr || insetMode?.dcltr)) ? `DCLTR-${mapMode?.dcltr || insetMode?.dcltr}` : s
}</span>
))}
</div>
</div>
{/* physical bezel keys — blank, aligned under the on-screen labels */}
<div className="softkeys">
{keys.map((s, i) => (
<button
key={i}
disabled={!s}
onClick={() => onSoftkey(i, s)}
className={`softkey ${s ? '' : 'empty'} ${s === 'CAUTION' ? 'caution' : ''} ${isOn(s) ? 'on' : ''}`}
>{s}</button>
<button key={i} disabled={!s} onClick={() => onSoftkey(i, s)}
className={`softkey ${s ? '' : 'empty'}`} aria-label={s || undefined} />
))}
</div>
</div>
<div className="bezel-knobs right">
<Knob label="COM" sub="VOL · PUSH SQ" fire={fire}
outer={['com_outer_up', 'com_outer_down']} inner={['com_inner_up', 'com_inner_down']} push="com12" />
<Knob label="CRS / BARO" sub="PUSH CRS CTR" fire={fire}
<Knob label="COM" sub="VOL · PUSH SQ" fire={fire} mode={knobMode}
outer={['com_outer_up', 'com_outer_down']} inner={['com_inner_up', 'com_inner_down']} push="com12"
swap={() => xp && xp.command('com1Swap')} emerg />
<Knob label="CRS / BARO" sub="PUSH CRS CTR" fire={fire} mode={knobMode}
outer={['crs_up', 'crs_down']} inner={['baro_up', 'baro_down']} push="crs_sync" />
<Knob label="RANGE" sub="PUSH PAN" joy fire={fire}
<Knob label="RANGE" sub="PUSH PAN" joy fire={fire} mode={knobMode}
outer={['range_up', 'range_down']} push="pan_push" pan />
<div className="bezel-grid">
<BtnG fire={fire} cmd="direct" onClick={onDirect}>D</BtnG><BtnG fire={fire} cmd="menu">MENU</BtnG>
<BtnG fire={fire} cmd="fpl">FPL</BtnG><BtnG fire={fire} cmd="proc" onClick={onProc}>PROC</BtnG>
<BtnG fire={fire} cmd="clr">CLR</BtnG><BtnG fire={fire} cmd="ent">ENT</BtnG>
<BtnG fire={fire} mode={knobMode} cmd="direct" onClick={onDirect}>D</BtnG><BtnG fire={fire} mode={knobMode} cmd="menu">MENU</BtnG>
<BtnG fire={fire} mode={knobMode} cmd="fpl" onClick={onFpl}>FPL</BtnG><BtnG fire={fire} mode={knobMode} cmd="proc" onClick={onProc}>PROC</BtnG>
<BtnG fire={fire} mode={knobMode} cmd="clr" onClick={onClr}>CLR</BtnG><BtnG fire={fire} mode={knobMode} cmd="ent">ENT</BtnG>
</div>
<Knob label="FMS" sub="PUSH CRSR" big fire={fire}
outer={['fms_outer_up', 'fms_outer_down']} inner={['fms_inner_up', 'fms_inner_down']} push="cursor" />
<Knob label="FMS" sub="PUSH CRSR" big fire={fire} mode={knobMode}
outer={['fms_outer_up', 'fms_outer_down']} inner={['fms_inner_up', 'fms_inner_down']} push="cursor"
onTurn={onFms} />
</div>
</div>
);
@@ -186,30 +219,53 @@ function APController({ xp }) {
// the mouse wheel; the inner ring via the top/bottom arrows (˄ ˅) and shift+wheel.
// Clicking the knob centre fires the push action (PUSH …). The RANGE knob also
// pans with a directional cross.
function Knob({ label, sub, outer, inner, push, big, joy, pan, fire }) {
function Knob({ label, sub, outer, inner, push, big, joy, pan, fire, mode = 'arrows', swap, emerg, onTurn }) {
// turn the outer ring: fire the sim command AND notify (e.g. cycle MFD page)
const outerStep = (dir) => { if (!outer) return; fire(dir > 0 ? outer[0] : outer[1]); if (onTurn) onTurn(dir); };
const onWheel = (e) => {
if (!outer) return;
e.preventDefault();
const set = (e.shiftKey && inner) ? inner : outer;
fire(e.deltaY < 0 ? set[0] : set[1]);
if (e.shiftKey && inner) fire(e.deltaY < 0 ? inner[0] : inner[1]);
else outerStep(e.deltaY < 0 ? 1 : -1);
};
const zoneClick = (e) => {
const r = e.currentTarget.getBoundingClientRect();
const dx = e.clientX - (r.left + r.width / 2);
const dy = e.clientY - (r.top + r.height / 2);
const rel = Math.hypot(dx, dy) / (r.width / 2);
if (rel < 0.42 && push) { fire(push); return; } // centre → PUSH
if (Math.abs(dy) >= Math.abs(dx)) outerStep(dy < 0 ? 1 : -1);
else if (inner) fire(dx > 0 ? inner[0] : inner[1]);
else outerStep(dx > 0 ? 1 : -1);
};
const zones = mode === 'zones';
return (
<div className={`knob-wrap ${big ? 'big' : ''}`}>
{swap && <button className="knob-swap" onClick={swap} title="Aktiv ↔ Standby"></button>}
{emerg && <span className="knob-emerg">EMERG</span>}
<span className="knob-lbl">{label}</span>
<div className="knob-cluster">
{inner && <button className="knob-arrow top" onClick={() => fire(inner[0])}>˄</button>}
{outer && <button className="knob-arrow left" onClick={() => fire(outer[1])}></button>}
<div className={`knob-cluster ${zones ? 'zones' : ''}`}>
{/* arrows mode (touch-friendly): visible ˄‹›˅ buttons. zones mode: click
the knob face itself (top/bottom = outer, left/right = inner). */}
{!zones && inner && <button className="knob-arrow top" onClick={() => fire(inner[0])}>˄</button>}
{!zones && outer && <button className="knob-arrow left" onClick={() => outerStep(-1)}></button>}
<button
className={`knob outer ${joy ? 'joy' : ''}`}
onWheel={onWheel}
onClick={() => push && fire(push)}
title={push ? 'PUSH' : ''}
onClick={zones ? zoneClick : (() => push && fire(push))}
title={zones ? `${outer ? 'oben/unten' : ''}${inner ? ' · links/rechts (fein)' : ''}${push ? ' · Mitte: PUSH' : ''}` : (push ? 'PUSH' : '')}
>
<span className="knob inner" />
{joy && <div className="joy-cross"></div>}
{joy && (<>
<span className="rng-ring" />
<span className="rng-arc l"></span>
<span className="rng-arc r"></span>
<span className="rng-sign m"></span>
<span className="rng-sign p">+</span>
</>)}
</button>
{outer && <button className="knob-arrow right" onClick={() => fire(outer[0])}></button>}
{inner && <button className="knob-arrow bottom" onClick={() => fire(inner[1])}>˅</button>}
{!zones && outer && <button className="knob-arrow right" onClick={() => outerStep(1)}></button>}
{!zones && inner && <button className="knob-arrow bottom" onClick={() => fire(inner[1])}>˅</button>}
</div>
{pan && (
<div className="pan-pad">
+19 -20
View File
@@ -49,41 +49,40 @@ export default function DirectTo({ xp, onClose }) {
};
return (
<div className="dlg-backdrop" onClick={onClose}>
<div className="gwin-backdrop" onClick={onClose}>
<div className="dlg dto" onClick={(e) => e.stopPropagation()}>
<div className="dlg-head"><span className="dto-arrow">D</span> DIRECT TO</div>
<div className="dlg-head">DIRECT TO</div>
<div className="dto-body">
<label className="dto-lbl">WAYPOINT</label>
{/* ident line (cyan, edited like the FMS knob) + resolved name below */}
<input
ref={inputRef}
className="dto-input"
className="dto-ident"
value={entry}
onChange={(e) => { setEntry(e.target.value.toUpperCase()); setSel(null); }}
onKeyDown={(e) => { if (e.key === 'Enter' && sel) activate(); if (e.key === 'Escape') onClose(); }}
placeholder="IDENT (z.B. KSEA, SEA, ELN)"
placeholder="_ _ _ _"
autoCapitalize="characters" autoCorrect="off" spellCheck="false"
/>
{hits.length > 0 && (
<div className="dto-name">{sel ? (sel.name || sel.type) : ' '}</div>
{hits.length > 0 && !sel && (
<div className="dto-hits">
{hits.map((h) => (
<button key={h.id + h.lat} className={sel && sel.id === h.id ? 'on' : ''}
onClick={() => { setSel(h); setEntry(h.id); setHits([]); }}>
<b>{h.id}</b><i>{h.type}</i><span>{h.lat.toFixed(2)}, {h.lon.toFixed(2)}</span>
<button key={h.id + h.lat} onClick={() => { setSel(h); setEntry(h.id); setHits([]); }}>
<b>{h.id}</b><span>{h.name || h.type}</span>
</button>
))}
</div>
)}
{sel && (
<div className="dto-sel">
<span className="dto-id">{sel.id}</span>
<span className="dto-type">{sel.type}</span>
{preview && <span className="dto-vec">{String(Math.round(preview.brg)).padStart(3, '0')}° · {preview.dist.toFixed(1)} NM</span>}
</div>
)}
</div>
<div className="dlg-actions">
<button className="fbtn" onClick={onClose}>CANCEL</button>
<button className="fbtn add" disabled={!sel} onClick={activate}>ACTIVATE</button>
<div className="dto-grid">
<b>ALT</b><span>_____FT</span><b>OFFSET</b><span>+0NM</span>
<b>BRG</b><span>{preview ? `${String(Math.round(preview.brg)).padStart(3, '0')}°` : '___°'}</span>
<b>DIS</b><span>{preview ? `${preview.dist.toFixed(1)}NM` : '__._NM'}</span>
<b>CRS</b><span>{preview ? `${String(Math.round(preview.brg)).padStart(3, '0')}°` : '___°'}</span>
<span /><span />
</div>
<div className="dto-foot">
<button className="dto-act" disabled={!sel} onClick={activate}>ACTIVATE</button>
</div>
</div>
</div>
</div>
+174
View File
@@ -0,0 +1,174 @@
import React, { useState, useEffect } from 'react';
import { num, navSearch, fmsList } from '../api/useXplane.js';
// G1000 ACTIVE FLIGHT PLAN page (MFD page group + PFD window). Shows the shared
// plan as WPT / DTK / DIS / CUM / ALT, active leg in magenta. Edit: type an
// ident to insert/append (resolved via X-Plane navdata), ✕ deletes, tap a row to
// make it the active leg; CLEAR / INVERT / EXPORT(.fms).
const R_NM = 3440.065, rad = (d) => d * Math.PI / 180, deg = (r) => r * 180 / Math.PI;
function distNm(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)));
}
function brng(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;
}
const fmtHrs = (h) => { const m = Math.round(h * 60); return `${Math.floor(m / 60)}:${String(m % 60).padStart(2, '0')}`; };
export default function FplPage({ xp, full = false, onClose }) {
const { flightPlan, fp, values, exportMsg } = xp;
const wps = flightPlan.waypoints || [];
const active = Math.max(1, Math.min(wps.length - 1, flightPlan.activeLeg ?? 1));
const [entry, setEntry] = useState('');
const [hits, setHits] = useState([]);
const [sel, setSel] = useState(-1); // selected row (insert cursor)
const [plans, setPlans] = useState(null); // saved .fms list (load picker)
const openLoad = async () => setPlans(await fmsList());
useEffect(() => {
const q = entry.trim();
if (q.length < 2 || /[,\s]/.test(q)) { setHits([]); return; }
let alive = true;
navSearch(q).then((r) => alive && setHits(r.slice(0, 6)));
return () => { alive = false; };
}, [entry]);
const addAt = async (ident, index) => {
const id = (ident || '').trim().toUpperCase();
if (!id) return;
const hits2 = await navSearch(id);
const hit = hits2[0];
if (!hit) return;
const next = wps.slice();
next.splice(index == null ? next.length : index, 0, { id: hit.id, lat: hit.lat, lon: hit.lon, type: hit.type || 'WPT', alt: null });
fp.set({ name: 'ACTIVE', waypoints: next, activeLeg: flightPlan.activeLeg ?? 1 });
setEntry(''); setHits([]);
};
const invert = () => {
if (wps.length < 2) return;
fp.set({ name: 'ACTIVE', waypoints: wps.slice().reverse(), activeLeg: 1 });
};
// rows with leg + cumulative distance
let cum = 0;
const rows = wps.map((w, i) => {
const prev = wps[i - 1];
const d = prev ? distNm(prev, w) : 0;
cum += d;
return { w, i, d, cum, dtk: prev ? Math.round(brng(prev, w)) : null, orig: i === 0 };
});
const total = cum;
const gs = num(values.groundspeed) * 1.94384;
const ete = gs > 30 ? total / gs : null;
// CURRENT VNV PROFILE: descent to the next waypoint with a lower target
// altitude (manual S.64/107). VS TGT for a -3° path, VS REQ to make it, V DEV
// from the path, time-to-top-of-descent.
const alt = num(values.altitude);
let vnav = null;
if (gs > 40) {
let c = 0, pl = num(values.lat), po = num(values.lon);
for (let i = Math.max(1, active); i < wps.length; i++) {
c += distNm({ lat: pl, lon: po }, wps[i]); pl = wps[i].lat; po = wps[i].lon;
const t = num(wps[i].alt);
if (t > 0 && t < alt - 50 && (wps[i].dsgn ?? true)) {
const tan = Math.tan((3 * Math.PI) / 180);
const vsTgt = -gs * tan * 101.27;
const vsReq = c > 0 ? (t - alt) / (c / gs * 60) : 0;
const vDev = alt - (t + c * 6076.12 * tan);
const todNm = c - (alt - t) / (6076.12 * tan);
vnav = { wptId: wps[i].id, tgtAlt: t, vsTgt, vsReq, vDev, fpa: 3.0, todSec: todNm > 0 ? (todNm / gs) * 3600 : 0 };
break;
}
}
}
const fmtSec = (s) => { const m = Math.floor(s / 60), ss = Math.round(s % 60); return `${m}:${String(ss).padStart(2, '0')}`; };
return (
<div className={`fpl ${full ? 'full' : 'win'}`}>
<div className="fpl-head">
<span>{full ? 'ACTIVE FLIGHT PLAN' : 'FLIGHTPLAN'}</span>
<span className="fpl-tot">{total.toFixed(0)} NM{ete ? ` · ${fmtHrs(ete)}` : ''}</span>
</div>
{!full && wps.length > 0 && (
<div className="fpl-od">{wps[0].id} / {wps[wps.length - 1].id}</div>
)}
<div className="fpl-cols"><span>WPT</span><span>DTK</span><span>DIS</span><span>CUM</span><span>ALT</span></div>
<div className="fpl-rows">
{rows.length === 0 && <div className="fpl-empty"> leer Ident unten eingeben</div>}
{rows.map(({ w, i, d, cum, dtk, orig }) => (
<div key={i} className={`fpl-row ${i === active ? 'act' : ''} ${i === sel ? 'sel' : ''}`}
onClick={() => { setSel(i); if (i >= 1) fp.setActive(i); }}>
<span className="r-wpt"><b className={i === active ? 'cur' : ''}>{w.id}</b><i>{w.type}</i></span>
<span className="r-dtk">{dtk == null ? '___' : `${String(dtk).padStart(3, '0')}°`}</span>
<span className="r-dis">{orig ? '—' : d.toFixed(1)}</span>
<span className="r-cum">{orig ? '—' : cum.toFixed(0)}</span>
<span className={`r-alt ${w.alt ? ((w.dsgn ?? true) ? 'dsgn' : 'refr') : ''}`}
title={w.alt ? 'Klick: Designated ↔ Reference' : ''}
onClick={(e) => {
e.stopPropagation(); if (!w.alt) return;
const next = wps.map((x, j) => (j === i ? { ...x, dsgn: !(x.dsgn ?? true) } : x));
fp.set({ name: 'ACTIVE', waypoints: next, activeLeg: flightPlan.activeLeg ?? 1 });
}}>{w.alt ? `${w.alt}FT` : '_____'}</span>
<button className="r-del" onClick={(e) => { e.stopPropagation(); fp.remove(i); }}></button>
</div>
))}
</div>
{full && (
<div className="fpl-vnav">
<div className="fpl-vnav-h">CURRENT VNV PROFILE</div>
{vnav ? (
<div className="fpl-vnav-grid">
<b>ACTIVE VNV WPT</b><span className="vwpt">{vnav.tgtAlt}<u>FT</u> at {vnav.wptId}</span>
<b>VS TGT</b><span>{Math.round(vnav.vsTgt)}<u>FPM</u></span><b>FPA</b><span>{vnav.fpa.toFixed(1)}°</span>
<b>VS REQ</b><span>{Math.round(vnav.vsReq)}<u>FPM</u></span><b>TIME TO TOD</b><span>{fmtSec(vnav.todSec)}</span>
<b>V DEV</b><span>{vnav.vDev >= 0 ? '+' : ''}{Math.round(vnav.vDev)}<u>FT</u></span>
</div>
) : <div className="fpl-vnav-none"> no active VNAV profile </div>}
</div>
)}
<div className="fpl-entry">
{hits.length > 0 && (
<div className="fpl-hits">
{hits.map((h) => (
<button key={h.id + h.lat} onClick={() => addAt(h.id, sel >= 0 ? sel : null)}>
<b>{h.id}</b><i>{h.type}</i><span>{h.lat.toFixed(2)}, {h.lon.toFixed(2)}</span>
</button>
))}
</div>
)}
<div className="fpl-inrow">
<input value={entry} onChange={(e) => setEntry(e.target.value.toUpperCase())}
onKeyDown={(e) => e.key === 'Enter' && addAt(entry, sel >= 0 ? sel : null)}
placeholder={sel >= 0 ? `Einfügen vor #${sel + 1}` : 'IDENT anhängen (z.B. ELN)'}
autoCapitalize="characters" autoCorrect="off" spellCheck="false" />
<button className="fpl-btn add" onClick={() => addAt(entry, sel >= 0 ? sel : null)}>EINFÜGEN</button>
</div>
<div className="fpl-actions">
<button className="fpl-btn" onClick={openLoad}>LADEN</button>
<button className="fpl-btn" onClick={() => { setSel(-1); fp.clear(); }} disabled={!wps.length}>CLEAR</button>
<button className="fpl-btn" onClick={invert} disabled={wps.length < 2}>INVERT</button>
<button className="fpl-btn" onClick={() => fp.export('WEBFPL')} disabled={wps.length < 2}>EXPORT .fms</button>
</div>
{exportMsg && <div className={`fpl-msg ${exportMsg.ok ? 'ok' : 'err'}`}>{exportMsg.ok ? 'Exportiert ✓' : exportMsg.error}</div>}
</div>
{plans && (
<div className="fpl-load" onClick={() => setPlans(null)}>
<div className="fpl-load-box" onClick={(e) => e.stopPropagation()}>
<div className="fpl-load-head"><span>Gespeicherte Flugpläne</span><button onClick={() => setPlans(null)}></button></div>
<div className="fpl-load-list">
{plans.length === 0 && <div className="fpl-empty">keine .fms in Output/FMS plans"</div>}
{plans.map((n) => (
<button key={n} onClick={() => { fp.load(n); setPlans(null); }}>{n}<i>.fms</i></button>
))}
</div>
</div>
</div>
)}
</div>
);
}
+69
View File
@@ -0,0 +1,69 @@
import React from 'react';
import { num } from '../api/useXplane.js';
// Bendix/King KAP 140 — the panel-mounted autopilot in the steam-gauge Cessna
// 172. A green segment LCD annunciates the active modes + armed altitude, with a
// row of buttons (AP HDG NAV APR REV ALT, UP/DN, BARO) and the ALT knob. Buttons
// fire X-Plane's own autopilot commands; annunciation reads the per-mode
// *_status datarefs (off/armed/active) — the same reliable source as the PFD,
// not the autopilot_state bitfield (whose bit positions don't match X-Plane).
export default function KAP140({ xp }) {
const { values: V, command, setDataref } = xp;
const lit = (k) => num(V[k]) > 0;
const eng = num(V.apEngaged) > 0 || num(V.apMode) >= 2;
const lat = lit('aprStatus') ? 'APR' : (lit('navStatus') || lit('gpssStatus')) ? 'NAV' : lit('bcStatus') ? 'REV' : lit('hdgStatus') ? 'HDG' : 'ROL';
const vert = lit('altStatus') ? 'ALT' : lit('vsStatus') ? 'VS' : '';
const selAlt = Math.round(num(V.apAltBug));
const vs = Math.round(num(V.apVsBug));
const Btn = ({ label, cmd }) => (
<button className="kap-btn" onClick={() => command(cmd)}>{label}</button>
);
// A rotary knob: click the upper half to step up, lower half to step down
// (also scroll). No +/- buttons.
const turn = (e, fn) => {
const r = e.currentTarget.getBoundingClientRect();
fn(((e.clientY - r.top) < r.height / 2) ? +1 : -1);
};
const altStep = (d) => setDataref('apAltBug', selAlt + d * 100);
return (
<div className="kap140">
<div className="kap-brand">KAP 140</div>
<div className="kap-lcd">
<div className="kap-l1">
<span className={eng ? 'an on' : 'an'}>AP</span>
<span className="an on">{lat}</span>
<span className="an on">{vert}</span>
</div>
<div className="kap-l2">
<span className="big">{selAlt}</span><span className="u">FT</span>
<span className="vs">{vs >= 0 ? '▲' : '▼'} {Math.abs(vs)}<span className="u">FPM</span></span>
</div>
</div>
<div className="kap-keys">
<Btn label="AP" cmd="apToggle" />
<Btn label="HDG" cmd="hdg" />
<Btn label="NAV" cmd="nav" />
<Btn label="APR" cmd="apr" />
<Btn label="REV" cmd="backCourse" />
<Btn label="ALT" cmd="altHold" />
<div className="kap-updn">
<button className="kap-btn sm" onClick={() => setDataref('apVsBug', vs + 100)}>UP</button>
<button className="kap-btn sm" onClick={() => setDataref('apVsBug', vs - 100)}>DN</button>
</div>
<button className="kap-btn" title="Baro set">BARO</button>
</div>
<div className="kap-knob">
<div className="kap-dial" title="ALT — oben +100 · unten 100"
onClick={(e) => turn(e, altStep)}
onWheel={(e) => { e.preventDefault(); altStep(e.deltaY < 0 ? 1 : -1); }}>
<span className="kdir up"></span>
<span className="kdir dn"></span>
</div>
<span className="kap-knoblbl">ALT</span>
</div>
</div>
);
}
+90 -35
View File
@@ -1,26 +1,67 @@
import React, { useState } from 'react';
import { num } from '../api/useXplane.js';
import MapView from './MapView.jsx';
import Nearest from './Nearest.jsx';
import FplPage from './FplPage.jsx';
const arr = (v, i = 0, d = 0) => (Array.isArray(v) ? num(v[i], d) : num(v, d));
const KG_PER_GAL = 2.72; // avgas
const navF = (v) => (num(v) / 100).toFixed(2);
const comF = (v) => (num(v) / 100).toFixed(3);
// Active flight-plan leg: distance / desired track / ETE to the active waypoint
// (great-circle from the aircraft), for the MFD nav data bar. Mirrors the PFD's
// activeNav so the two displays agree.
const R_NM = 3440.065, D2R = Math.PI / 180, R2D = 180 / Math.PI;
function legNav(V, fp) {
const wps = fp?.waypoints || [];
const ai = Math.max(1, Math.min(wps.length - 1, fp?.activeLeg ?? 1));
const wp = wps[ai];
const lat = num(V.lat), lon = num(V.lon);
if (!wp || (!lat && !lon)) return null;
const dLat = (wp.lat - lat) * D2R, dLon = (wp.lon - lon) * D2R;
const a = Math.sin(dLat / 2) ** 2 + Math.cos(lat * D2R) * Math.cos(wp.lat * D2R) * Math.sin(dLon / 2) ** 2;
const dist = 2 * R_NM * Math.asin(Math.min(1, Math.sqrt(a)));
const y = Math.sin(dLon) * Math.cos(wp.lat * D2R);
const x = Math.cos(lat * D2R) * Math.sin(wp.lat * D2R) - Math.sin(lat * D2R) * Math.cos(wp.lat * D2R) * Math.cos(dLon);
const dtk = (Math.atan2(y, x) * R2D + 360) % 360;
const gs = num(V.groundspeed) * 1.94384;
return { id: wp.id, dist, dtk, ete: gs > 20 ? (dist / gs) * 3600 : null };
}
const fmtEte = (s) => {
if (s == null) return '__:__';
const m = Math.floor(s / 60), ss = Math.round(s % 60);
return m < 60 ? `${m}:${String(ss).padStart(2, '0')}` : `${Math.floor(m / 60)}+${String(m % 60).padStart(2, '0')}`;
};
// G1000 MFD — full-width NAV/COM bar on top, the engine instrument strip (EIS)
// down the left as real bar gauges, and the moving map (X-Plane nav data) with
// G1000 chrome (compass rose, range, NORTH UP, mode) filling the rest.
export default function MFD({ values: V, flightPlan, fp, mapMode }) {
const MFD_PAGES = [{ id: 'map', name: 'MAP' }, { id: 'fpl', name: 'FPL' }, { id: 'nrst', name: 'NRST' }];
export default function MFD({ values: V, flightPlan, fp, mapMode, page = 'map', onCycle, xp }) {
const [rangeNm, setRangeNm] = useState(8);
const idx = Math.max(0, MFD_PAGES.findIndex((p) => p.id === page));
return (
<div className="mfd-g1000">
<MfdTopBar V={V} />
<MfdTopBar V={V} fp={flightPlan} />
<div className="mfd-body">
<EisStrip V={V} />
<div className="mfd-map">
<MapView values={V} flightPlan={flightPlan} fp={fp} hud={false}
mapMode={mapMode} dcltr={mapMode?.dcltr || 0} onView={({ rangeNm }) => setRangeNm(rangeNm)} />
<MapChrome V={V} rangeNm={rangeNm} />
{/* MapView stays mounted (keeps tiles warm) but is hidden under NRST */}
<div style={{ position: 'absolute', inset: 0, visibility: page === 'map' ? 'visible' : 'hidden' }}>
<MapView values={V} flightPlan={flightPlan} fp={fp} hud={false}
mapMode={mapMode} dcltr={mapMode?.dcltr || 0} rangeNm={num(V.uiMapRange) || undefined}
terrain={xp?.terrain} rose onView={({ rangeNm }) => setRangeNm(rangeNm)} />
<MapChrome V={V} rangeNm={rangeNm} />
</div>
{page === 'nrst' && <Nearest values={V} full />}
{page === 'fpl' && xp && <FplPage xp={xp} full />}
{/* page-group indicator (bottom-right), like the real G1000 — selected
by the FMS knob; tappable as a touch fallback. */}
<button className="mfd-pageind" onClick={() => onCycle && onCycle(1)} title="Seite (FMS-Knopf)">
<span>{MFD_PAGES[idx].name}</span>
{MFD_PAGES.map((p, i) => <em key={p.id} className={i === idx ? 'on' : ''} />)}
</button>
</div>
</div>
</div>
@@ -28,33 +69,47 @@ export default function MFD({ values: V, flightPlan, fp, mapMode }) {
}
/* ---------------- top NAV/COM bar ---------------- */
function MfdTopBar({ V }) {
function MfdTopBar({ V, fp }) {
const gs = Math.round(num(V.groundspeed) * 1.94384);
const trk = String(Math.round(num(V.track)) % 360).padStart(3, '0');
const leg = legNav(V, fp);
const dtk = leg ? `${String(Math.round(leg.dtk) % 360).padStart(3, '0')}°` : '___°';
const swap = (x, y) => <text x={x} y={y} fill="#0ff" fontSize="16" textAnchor="middle"></text>;
return (
<svg className="mfd-topbar" viewBox="0 0 1000 70" preserveAspectRatio="none" fontFamily="monospace">
<rect x="0" y="0" width="1000" height="70" fill="#000" />
{[300, 660].map((x) => <line key={x} x1={x} y1="2" x2={x} y2="68" stroke="#333" strokeWidth="1.5" />)}
<line x1="0" y1="70" x2="1000" y2="70" stroke="#3a3a3a" strokeWidth="2" />
{/* NAV1 / NAV2 */}
{/* NAV1 / NAV2 — standby LEFT (cyan, boxed), active RIGHT (white) per manual */}
<text x="10" y="27" fill="#fff" fontSize="13">NAV1</text>
<rect x="50" y="11" width="80" height="21" fill="none" stroke="#0ff" strokeWidth="1.3" />
<text x="126" y="27" fill="#0ff" fontSize="17" textAnchor="end">{navF(V.nav1)}</text>
<text x="126" y="27" fill="#0ff" fontSize="17" textAnchor="end">{navF(V.nav1Sb)}</text>
{swap(150, 27)}
<text x="174" y="27" fill="#fff" fontSize="17">{navF(V.nav1Sb)}</text>
<text x="174" y="27" fill="#fff" fontSize="17">{navF(V.nav1)}</text>
<text x="10" y="58" fill="#fff" fontSize="13">NAV2</text>
<text x="126" y="58" fill="#fff" fontSize="17" textAnchor="end">{navF(V.nav2)}</text>
<text x="174" y="58" fill="#fff" fontSize="17">{navF(V.nav2Sb)}</text>
<text x="126" y="58" fill="#0ff" fontSize="17" textAnchor="end">{navF(V.nav2Sb)}</text>
<text x="174" y="58" fill="#fff" fontSize="17">{navF(V.nav2)}</text>
{/* centre: GS/DTK/TRK/ETE + active mode line */}
<text x="312" y="27" fill="#fff" fontSize="13">GS</text>
<text x="350" y="27" fill="#e040fb" fontSize="15" fontWeight="bold">{gs}</text>
<text x="378" y="27" fill="#0c9" fontSize="11">KT</text>
<text x="410" y="27" fill="#fff" fontSize="13">DTK</text>
<text x="448" y="27" fill="#e040fb" fontSize="15" fontWeight="bold">{dtk}</text>
<text x="520" y="27" fill="#fff" fontSize="13">TRK</text>
<text x="560" y="27" fill="#e040fb" fontSize="15" fontWeight="bold">{trk}°</text>
<text x="610" y="27" fill="#fff" fontSize="13">ETE</text>
<text x="480" y="58" fill="#0ff" fontSize="15" textAnchor="middle">NAV DEFAULT NAV</text>
<text x="648" y="27" fill="#fff" fontSize="15">{fmtEte(leg?.ete)}</text>
{/* active leg (centre): → waypoint + distance, or no-flight-plan note */}
{leg ? (
<g>
<text x="412" y="58" fill="#e040fb" fontSize="16"></text>
<text x="432" y="58" fill="#fff" fontSize="16" fontWeight="bold">{leg.id}</text>
<text x="520" y="58" fill="#0ff" fontSize="15">{leg.dist.toFixed(1)}</text>
<text x="566" y="58" fill="#0c9" fontSize="11">NM</text>
</g>
) : (
<text x="480" y="58" fill="#777" fontSize="14" textAnchor="middle">NO ACTIVE WAYPOINT</text>
)}
{/* COM1 / COM2 */}
<text x="690" y="27" fill="#0f0" fontSize="17">{comF(V.com1)}</text>
{swap(818, 27)}
@@ -73,12 +128,19 @@ function EisStrip({ V }) {
const rpm = arr(V.engRpm);
const ffGph = (arr(V.fuelFlow) * 3600) / KG_PER_GAL;
const oilPsi = arr(V.oilPress);
const oilF = arr(V.oilTemp) * 9 / 5 + 32;
const egtF = arr(V.egt) * 9 / 5 + 32;
// X-Plane's temperature indicator datarefs may already honor the user's unit
// (°F) despite the "_deg_C" name. Auto-detect: only convert if it still looks
// like Celsius, so we don't double-convert (which pegged the gauges red).
const oilT = arr(V.oilTemp), egtT = arr(V.egt);
const oilF = oilT > 150 ? oilT : oilT * 9 / 5 + 32;
const egtF = egtT > 900 ? egtT : egtT * 9 / 5 + 32;
const fuelL = arr(V.fuelQty, 0) / KG_PER_GAL;
const fuelR = arr(V.fuelQty, 1) / KG_PER_GAL;
const volts = arr(V.volts, 0, 28);
const amps = arr(V.amps);
const voltsM = arr(V.volts, 0, 28); // main bus
const voltsE = arr(V.volts, 1, voltsM); // essential bus (falls back to main)
const ampsM = arr(V.genAmps, 0); // alternator (M)
const ampsS = arr(V.amps, 0); // battery (S)
const engHrs = num(V.engHrs) / 3600;
return (
<svg className="eis-svg" viewBox="0 0 190 540" preserveAspectRatio="xMidYMin meet" fontFamily="monospace">
<rect x="0" y="0" width="190" height="540" fill="#0a0a0a" />
@@ -94,20 +156,20 @@ function EisStrip({ V }) {
zones={[{ from: 4.5, to: 5.5, c: '#0c0' }]} />
<FuelBar y={330} left={fuelL} right={fuelR} />
<text x="8" y="412" fill="#39d3c0" fontSize="12">ENG</text>
<text x="182" y="412" fill="#fff" fontSize="14" textAnchor="end">0.0 HRS</text>
<text x="182" y="412" fill="#fff" fontSize="14" textAnchor="end">{engHrs.toFixed(1)} HRS</text>
<text x="95" y="438" fill="#39d3c0" fontSize="12" textAnchor="middle"> ELECTRICAL </text>
<text x="20" y="462" fill="#fff" fontSize="12">M</text>
<text x="95" y="462" fill="#39d3c0" fontSize="12" textAnchor="middle">BUS</text>
<text x="170" y="462" fill="#fff" fontSize="12" textAnchor="end">E</text>
<text x="18" y="482" fill="#fff" fontSize="15">{volts.toFixed(1)}</text>
<text x="18" y="482" fill="#fff" fontSize="15">{voltsM.toFixed(1)}</text>
<text x="95" y="482" fill="#39d3c0" fontSize="11" textAnchor="middle">VOLTS</text>
<text x="172" y="482" fill="#fff" fontSize="15" textAnchor="end">{volts.toFixed(1)}</text>
<text x="172" y="482" fill="#fff" fontSize="15" textAnchor="end">{voltsE.toFixed(1)}</text>
<text x="20" y="506" fill="#fff" fontSize="12">M</text>
<text x="95" y="506" fill="#39d3c0" fontSize="12" textAnchor="middle">BATT</text>
<text x="170" y="506" fill="#fff" fontSize="12" textAnchor="end">S</text>
<text x="18" y="526" fill="#fff" fontSize="15">{amps >= 0 ? '+' : ''}{amps.toFixed(1)}</text>
<text x="18" y="526" fill="#fff" fontSize="15">{ampsM >= 0 ? '+' : ''}{ampsM.toFixed(1)}</text>
<text x="95" y="526" fill="#39d3c0" fontSize="11" textAnchor="middle">AMPS</text>
<text x="172" y="526" fill="#fff" fontSize="15" textAnchor="end">+0.0</text>
<text x="172" y="526" fill="#fff" fontSize="15" textAnchor="end">{ampsS >= 0 ? '+' : ''}{ampsS.toFixed(1)}</text>
</svg>
);
}
@@ -187,24 +249,17 @@ function niceRange(nm) { let r = NICE[0]; for (const s of NICE) if (nm >= s) r =
function MapChrome({ V, rangeNm }) {
const gs = Math.round(num(V.groundspeed) * 1.94384);
const rng = niceRange(rangeNm);
const cx = 160, cy = 160, r = 150;
const ticks = [];
for (let d = 0; d < 360; d += 10) {
const a = ((d - 90) * Math.PI) / 180;
const big = d % 30 === 0;
const r2 = r - (big ? 12 : 7);
ticks.push(<line key={d} x1={cx + r * Math.cos(a)} y1={cy + r * Math.sin(a)} x2={cx + r2 * Math.cos(a)} y2={cy + r2 * Math.sin(a)} stroke="#cfd6dd" strokeWidth={big ? 2 : 1} />);
if (big) {
const lbl = d === 0 ? 'N' : d === 90 ? 'E' : d === 180 ? 'S' : d === 270 ? 'W' : d / 10;
ticks.push(<text key={'l' + d} x={cx + (r - 26) * Math.cos(a)} y={cy + (r - 26) * Math.sin(a) + 5} fill="#fff" fontSize="15" textAnchor="middle" fontFamily="monospace">{lbl}</text>);
}
}
const wd = ((Math.round(num(V.windDir)) % 360) + 360) % 360, ws = Math.round(num(V.windSpd));
return (
<div className="map-chrome">
<svg className="map-rose" viewBox="0 0 320 320">{ticks}</svg>
{/* the compass rose now lives in MapView, anchored to the aircraft */}
<div className="mc-tr"><b>{gs} KT</b><span>NORTH UP</span></div>
<div className="mc-wind">
{ws >= 1
? (<><span className="mc-windarr" style={{ transform: `rotate(${wd + 180}deg)` }}></span><span>{String(wd).padStart(3, '0')}° {ws}<i>kt</i></span></>)
: <span>CALM</span>}
</div>
<div className="mc-range">{rng} NM</div>
<div className="mc-mode">NAV <em className="on" /><em /><em /><em /><em /></div>
</div>
);
}
+213 -17
View File
@@ -7,6 +7,20 @@ const PLANE_SVG =
'<svg viewBox="0 0 24 24" width="34" height="34"><path fill="#ffd400" stroke="#000" stroke-width="1" ' +
'd="M12 2l1.5 6.5L22 13v2l-8.5-2.5L13 21l2 1v1l-3-1-3 1v-1l2-1-.5-8.5L2 15v-2l8.5-4.5z"/></svg>';
// Compass rose anchored to the ownship (north-up), built once. As a Leaflet
// marker it tracks the aircraft on every pan — so it always wraps the plane
// instead of drifting with the screen.
const ROSE_PX = 360;
const ROSE_HTML = (() => {
const cx = 180, cy = 180, r = 170; let t = '';
for (let d = 0; d < 360; d += 10) {
const a = ((d - 90) * Math.PI) / 180, big = d % 30 === 0, r2 = r - (big ? 13 : 7);
t += `<line x1="${cx + r * Math.cos(a)}" y1="${cy + r * Math.sin(a)}" x2="${cx + r2 * Math.cos(a)}" y2="${cy + r2 * Math.sin(a)}" stroke="#cfd6dd" stroke-width="${big ? 2 : 1}"/>`;
if (big) { const lbl = d === 0 ? 'N' : d === 90 ? 'E' : d === 180 ? 'S' : d === 270 ? 'W' : d / 10; t += `<text x="${cx + (r - 28) * Math.cos(a)}" y="${cy + (r - 28) * Math.sin(a) + 5}" fill="#fff" font-size="16" text-anchor="middle" font-family="monospace">${lbl}</text>`; }
}
return `<svg width="${ROSE_PX}" height="${ROSE_PX}" viewBox="0 0 360 360">${t}</svg>`;
})();
// A single nav feature rendered as G1000-style symbology: cyan airport, green
// VOR hexagon, brown NDB dot-ring, light fix triangle — with an optional label.
function navSymbol(f, label) {
@@ -43,15 +57,40 @@ const TILES = {
dark: null,
};
export default function MapView({ values, flightPlan, fp, inset = false, hud = true, mapMode, dcltr = 0, onView }) {
// 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);
const acRef = useRef(null);
const roseRef = useRef(null);
const routeRef = useRef(null);
const wpLayerRef = useRef(null);
const navLayerRef = useRef(null);
const navAbortRef = useRef(null);
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;
@@ -61,8 +100,12 @@ export default function MapView({ values, flightPlan, fp, inset = false, hud = t
const track = num(values.track);
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;
// Swap the base tile layer (and report it via the container's dark class).
const applyBase = (map, name) => {
@@ -82,24 +125,90 @@ export default function MapView({ values, flightPlan, fp, inset = false, hud = t
// create map once
useEffect(() => {
const map = L.map(elRef.current, { zoomControl: !inset, attributionControl: false, dragging: !inset, scrollWheelZoom: !inset })
const map = L.map(elRef.current, { zoomControl: !inset, attributionControl: false, dragging: !inset, scrollWheelZoom: !inset, zoomSnap: 0 })
.setView([lat, lon], inset ? 10 : 9);
applyBase(map, base);
navLayerRef.current = L.layerGroup().addTo(map); // real airports/navaids/fixes
// dedicated pane for the terrain-awareness overlay: above the base tiles
// (z 200) but below the route / nav symbols (overlayPane z 400)
map.createPane('terrain');
map.getPane('terrain').style.zIndex = 250;
map.getPane('terrain').style.pointerEvents = 'none';
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);
// AIRWAYS overlay (Victor/Jet routes from X-Plane's earth_awy.dat). Light
// blue lines with the airway name at the segment midpoint (labels ≥ z8).
const refreshAirways = async () => {
const layer = awyLayerRef.current;
if (!layer) return;
if (!awyOnRef.current) { layer.clearLayers(); return; }
const b = map.getBounds();
try {
const res = await fetch(`/api/nav/airways?s=${b.getSouth()}&w=${b.getWest()}&n=${b.getNorth()}&e=${b.getEast()}&limit=600`);
if (!res.ok) return;
const segs = await res.json();
layer.clearLayers();
const labels = map.getZoom() >= 8;
const seen = new Set();
for (const sg of segs) {
L.polyline([[sg.la1, sg.lo1], [sg.la2, sg.lo2]], { color: '#5db4e6', weight: 1.2, opacity: 0.7, interactive: false }).addTo(layer);
if (labels && sg.name && !seen.has(sg.name)) {
seen.add(sg.name);
L.marker([(sg.la1 + sg.la2) / 2, (sg.lo1 + sg.lo2) / 2], {
icon: L.divIcon({ className: 'awy-divicon', html: `<span class='awy-lbl'>${sg.name}</span>`, iconSize: [0, 0] }),
interactive: false,
}).addTo(layer);
}
}
} catch { /* offline */ }
};
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;
// DCLTR-2 and above declutter Special-Use Airspace (manual p.56), so hide
// the airspace overlay at those levels even when the AIRSPACE key is on.
if (!aspOnRef.current || map.getZoom() < 6 || (dcltrRef.current || 0) >= 2) { 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 () => {
const layer = navLayerRef.current;
if (!layer) return;
const z = map.getZoom();
if (z < 6 || dcltrRef.current > 0) { layer.clearLayers(); return; }
const types = z >= 10 ? 'apt,vor,ndb,fix' : z >= 8 ? 'apt,vor,ndb' : 'apt';
const dc = dcltrRef.current || 0; // 0 all · 1 drop fixes · 2 drop fixes+NDB · 3 flight-plan only
if (z < 6 || dc >= 3) { layer.clearLayers(); return; }
let types = z >= 10 ? ['apt', 'vor', 'ndb', 'fix'] : z >= 8 ? ['apt', 'vor', 'ndb'] : ['apt'];
if (dc >= 1) types = types.filter((t) => t !== 'fix');
if (dc >= 2) types = types.filter((t) => t !== 'ndb');
const b = map.getBounds();
const url = `/api/nav/bbox?s=${b.getSouth()}&w=${b.getWest()}&n=${b.getNorth()}&e=${b.getEast()}&types=${types}&limit=${z >= 10 ? 500 : 250}`;
const url = `/api/nav/bbox?s=${b.getSouth()}&w=${b.getWest()}&n=${b.getNorth()}&e=${b.getEast()}&types=${types.join(',')}&limit=${z >= 10 ? 500 : 250}`;
try {
navAbortRef.current?.abort();
navAbortRef.current = new AbortController();
@@ -111,10 +220,18 @@ 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(); reportView(map); });
map.on('zoomend', () => reportView(map));
setTimeout(() => { refreshNav(); 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) {
roseRef.current = L.marker([lat, lon], {
icon: L.divIcon({ className: 'rose-divicon', html: ROSE_HTML, iconSize: [ROSE_PX, ROSE_PX], iconAnchor: [ROSE_PX / 2, ROSE_PX / 2] }),
interactive: false, zIndexOffset: 600, pane: 'terrain',
}).addTo(map);
}
const icon = L.divIcon({ className: 'ac-divicon', html: PLANE_SVG, iconSize: [34, 34], iconAnchor: [17, 17] });
acRef.current = L.marker([lat, lon], { icon, interactive: false, zIndexOffset: 1000 }).addTo(map);
@@ -134,23 +251,102 @@ export default function MapView({ values, flightPlan, fp, inset = false, hud = t
if (map) applyBase(map, base);
}, [base]); // eslint-disable-line
// 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,
// yellow 1001000 ft below, transparent otherwise (G1000 TAWS colours). Only
// when the TERRAIN base is selected.
useEffect(() => {
const map = mapRef.current;
if (!map) return;
const t = terrain;
const show = !!mapMode?.terrain && t && Array.isArray(t.elev) && t.elev.length === t.rows * t.cols;
if (!show) {
if (terrRef.current) { map.removeLayer(terrRef.current); terrRef.current = null; }
return;
}
const cv = document.createElement('canvas');
cv.width = t.cols; cv.height = t.rows;
const ctx = cv.getContext('2d');
const img = ctx.createImageData(t.cols, t.rows);
for (let i = 0; i < t.elev.length; i++) {
const ev = t.elev[i], diff = ev - t.alt;
let R = 0, G = 0, B = 0, A = 0;
if (ev > 0) {
if (diff > -100) { R = 214; G = 22; B = 22; A = 185; } // red
else if (diff > -1000) { R = 216; G = 168; B = 20; A = 150; } // yellow
}
const p = i * 4; img.data[p] = R; img.data[p + 1] = G; img.data[p + 2] = B; img.data[p + 3] = A;
}
ctx.putImageData(img, 0, 0);
const bounds = [[t.s, t.w], [t.n, t.e]];
if (!terrRef.current) {
terrRef.current = L.imageOverlay(cv.toDataURL(), bounds, { opacity: 0.6, interactive: false, pane: 'terrain' }).addTo(map);
} else {
terrRef.current.setBounds(bounds);
terrRef.current.setUrl(cv.toDataURL());
}
}, [terrain, mapMode?.terrain]); // eslint-disable-line
// G1000 UI sync: follow the in-sim map range (centre→top-edge NM). Inverse of
// reportView: solve for the zoom that yields the target range at this latitude
// and viewport height. Only when the sim publishes it (rangeNm > 0).
useEffect(() => {
const map = mapRef.current;
if (!map || !(rangeNm > 0)) return;
const H = map.getSize().y;
if (!H) return;
const z = Math.log2((156543.03392 * Math.cos(lat * Math.PI / 180) * (H / 2)) / (rangeNm * 1852));
const zc = Math.max(3, Math.min(17, z));
if (Math.abs(map.getZoom() - zc) > 0.04) map.setZoom(zc, { animate: true });
}, [rangeNm]); // eslint-disable-line
// declutter: hide nav symbology, or repopulate it, when the level changes
useEffect(() => {
const map = mapRef.current;
if (!map) return;
if (dcltr > 0) navLayerRef.current?.clearLayers();
else map.fire('moveend'); // triggers refreshNav to redraw symbols
refreshAirspaceRef.current && refreshAirspaceRef.current(); // re-eval SUA declutter (DCLTR-2)
}, [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]);
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.
+29 -21
View File
@@ -19,7 +19,7 @@ const freqStr = (f, type) => {
return type === 'vor' ? (n / 100).toFixed(2) : String(n);
};
export default function Nearest({ values, onClose }) {
export default function Nearest({ values, onClose, full = false }) {
const [type, setType] = useState('apt');
const [rows, setRows] = useState([]);
const lastRef = useRef(null);
@@ -42,35 +42,43 @@ export default function Nearest({ values, onClose }) {
}, [type, Math.round(lat * 50), Math.round(lon * 50)]); // re-key on ~1nm moves
return (
<div className="nrst-window">
<div className={`nrst-window ${full ? 'full' : ''}`}>
<div className="nrst-head">
<span className="nrst-title">NEAREST</span>
<span className="nrst-title">NEAREST {type === 'apt' ? 'AIRPORTS' : type === 'vor' ? 'VOR' : 'NDB'}</span>
<div className="nrst-tabs">
{TABS.map((t) => (
<button key={t.id} className={type === t.id ? 'on' : ''} onClick={() => setType(t.id)}>{t.label}</button>
))}
</div>
{onClose && <button className="nrst-x" onClick={onClose}></button>}
</div>
<div className="nrst-cols">
<span className="c-id">{type === 'apt' ? 'IDENT' : 'IDENT'}</span>
<span className="c-brg">BRG</span>
<span className="c-dis">DIS</span>
<span className="c-xtra">{type === 'apt' ? 'ELEV' : 'FREQ'}</span>
</div>
<div className="nrst-list">
{rows.length === 0 && <div className="nrst-empty"> no data </div>}
{rows.map((f, i) => (
<div className="nrst-row" key={f.id + i}>
<span className="c-id">{f.id}</span>
<span className="c-brg">{String(num(f.brg)).padStart(3, '0')}°</span>
<span className="c-dis">{num(f.dist).toFixed(1)}<u>nm</u></span>
<span className="c-xtra">
{type === 'apt' ? `${Math.round(num(f.elev))}ft` : freqStr(f.freq, type)}
</span>
{f.name && <span className="c-name">{f.name}</span>}
</div>
))}
{type === 'apt'
? rows.map((f, i) => (
<div className="apt-entry" key={f.id + i}>
<div className="apt-l1">
<span className={`apt-id ${i === 0 ? 'cur' : ''}`}>{f.id}</span>
<span className="apt-brg">{String(num(f.brg)).padStart(3, '0')}°</span>
<span className="apt-dis">{num(f.dist).toFixed(1)}<u>NM</u></span>
<span className={`apt-app ${f.app === 'ILS' ? 'ils' : ''}`}>{f.app || 'VFR'}</span>
</div>
<div className="apt-l2">
<span className="apt-comlbl">{f.com ? f.com.label : ''}</span>
<span className="apt-com">{f.com ? f.com.freq.toFixed(3) : ''}</span>
<span className="apt-rwlbl">RNWY</span>
<span className="apt-rw">{f.rwyFt ? `${f.rwyFt}FT` : '—'}</span>
</div>
</div>
))
: rows.map((f, i) => (
<div className="nrst-row" key={f.id + i}>
<span className="c-id">{f.id}</span>
<span className="c-brg">{String(num(f.brg)).padStart(3, '0')}°</span>
<span className="c-dis">{num(f.dist).toFixed(1)}<u>nm</u></span>
<span className="c-xtra">{freqStr(f.freq, type)}</span>
{f.name && <span className="c-name">{f.name}</span>}
</div>
))}
</div>
</div>
);
+450 -91
View File
@@ -1,8 +1,10 @@
import React, { useRef, useState, useLayoutEffect, Suspense, lazy } from 'react';
import { num } from '../api/useXplane.js';
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';
import RadioTuner from './RadioTuner.jsx';
// Lazy-load the heavy WebGL terrain engine only when the PFD is shown.
const SVT = lazy(() => import('./SVT.jsx'));
@@ -57,6 +59,10 @@ function fmtEte(s) {
}
// VNAV: nearest downstream waypoint with a lower altitude constraint, and the
// vertical speed required to meet it at the current groundspeed.
// VNAV descent profile to the next waypoint that has a (lower) target altitude:
// target VS for a -3° flight path, required VS to make the restriction, vertical
// deviation from that path, and time-to-top-of-descent. (Manual S.64 / S.107.)
const VNAV_FPA = 3.0; // default flight-path angle (degrees)
function vnavInfo(V, fp) {
const wps = fp?.waypoints || [];
const ai = Math.max(1, Math.min(wps.length - 1, fp?.activeLeg ?? 1));
@@ -69,10 +75,17 @@ function vnavInfo(V, fp) {
cum += brgDist(prevLat, prevLon, wps[i].lat, wps[i].lon).dist;
prevLat = wps[i].lat; prevLon = wps[i].lon;
const tgt = num(wps[i].alt);
if (tgt > 0 && tgt < alt - 50) {
if (tgt > 0 && tgt < alt - 50 && (wps[i].dsgn ?? true)) {
const tan = Math.tan((VNAV_FPA * Math.PI) / 180);
const tMin = (cum / gs) * 60;
const vsReq = tMin > 0 ? (tgt - alt) / tMin : 0;
return { wptId: wps[i].id, tgtAlt: tgt, dist: cum, vsReq };
const vsReq = tMin > 0 ? (tgt - alt) / tMin : 0; // fpm to make the fix
const vsTgt = -gs * tan * 101.27; // fpm for the FPA at this GS
const desiredAltNow = tgt + cum * 6076.12 * tan; // path altitude at present position
const vDev = alt - desiredAltNow; // + = above path
const descentNm = (alt - tgt) / (6076.12 * tan); // distance the descent itself takes
const todNm = cum - descentNm; // distance ahead until TOD
const todSec = todNm > 0 ? (todNm / gs) * 3600 : 0;
return { wptId: wps[i].id, tgtAlt: tgt, dist: cum, vsReq, vsTgt, vDev, fpa: VNAV_FPA, todSec };
}
}
return null;
@@ -87,7 +100,7 @@ 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 };
export default function PFD({ values: V, svt = true, inset = false, insetMode, nrst = false, onCloseNrst, tmr = false, onCloseTmr, flightPlan, fp }) {
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);
const [box, setBox] = useState(null);
@@ -106,6 +119,15 @@ export default function PFD({ values: V, svt = true, inset = false, insetMode, n
const map = (b) => ({ left: offX + b.x * scale, top: offY + b.y * scale, width: b.w * scale, height: b.h * scale });
setBox(map(SVT_BOX));
setInsetBox(map(INSET_BOX));
// Window zone (lower-right quadrant): right = altitude tape's right edge
// (x≈938), bottom just above the XPDR strip (y≈736), left clear of the HSI
// rose (x≈650), top below the baro box (y≈502). Exposed as CSS vars so the
// windows sit embedded and never cover the HSI or the baro readout.
const root = document.documentElement;
root.style.setProperty('--gwin-right', `${Math.max(8, wr.width - (offX + 938 * scale))}px`);
root.style.setProperty('--gwin-bottom', `${Math.max(8, wr.height - (offY + 736 * scale))}px`);
root.style.setProperty('--gwin-maxw', `${(938 - 650) * scale}px`);
root.style.setProperty('--gwin-maxh', `${(736 - 502) * scale}px`);
};
measure();
const ro = new ResizeObserver(measure);
@@ -116,6 +138,23 @@ export default function PFD({ values: V, svt = true, inset = false, insetMode, n
const nav = activeNav(V, flightPlan);
const vnav = vnavInfo(V, flightPlan);
// GPS phase annunciation: APR when an approach leg is active, TERM within 30 nm
// of the destination, otherwise ENR (manual).
const gpsPhase = (() => {
const wps = flightPlan?.waypoints || [];
if (!wps.length) return 'ENR';
if (wps.some((w) => w.appr)) return 'APR';
const d = wps[wps.length - 1];
const dd = brgDist(num(V.lat), num(V.lon), d.lat, d.lon).dist;
return dd < 30 ? 'TERM' : 'ENR';
})();
const [tune, setTune] = useState(null); // radio being tuned (tap a freq)
// Eased values so the tapes + heading rose glide between X-Plane's ~20 Hz
// samples (VSI a touch softer; attitude is smoothed separately, imperatively).
const iasS = useEased(num(V.airspeed));
const altS = useEased(num(V.altitude));
const vsS = useEased(num(V.vspeed), 0.12);
const hdgS = useEasedAngle(((num(V.heading) % 360) + 360) % 360);
return (
<div className="pfd-wrap" ref={wrapRef}>
@@ -127,7 +166,7 @@ export default function PFD({ values: V, svt = true, inset = false, insetMode, n
{inset && insetBox && (
<div className="pfd-inset" style={insetBox}>
<MapView values={V} flightPlan={flightPlan} fp={fp} inset
mapMode={insetMode} dcltr={insetMode?.dcltr || 0} />
mapMode={insetMode} dcltr={insetMode?.dcltr || 0} rangeNm={num(V.uiMapRange) || undefined} />
</div>
)}
<svg ref={svgRef} className="g1000" viewBox={`0 0 ${W} ${H}`} preserveAspectRatio="xMidYMid meet">
@@ -140,19 +179,35 @@ export default function PFD({ values: V, svt = true, inset = false, insetMode, n
</linearGradient>
</defs>
{!svt && <rect x="0" y="0" width={W} height={H} fill="#000" />}
<RadioBar V={V} />
<RadioBar V={V} onTune={command ? setTune : null} />
{nav && <NavStatus nav={nav} />}
{vnav && <VnavBox vnav={vnav} />}
<Attitude V={V} svt={svt} />
<AirspeedTape V={V} />
<AltitudeTape V={V} />
<AFCS V={V} />
<Marker V={V} />
<AirspeedTape V={V} ias={iasS} />
<AltitudeTape V={V} alt={altS} vs={vsS} baroHpa={baroHpa} minimums={minimums} vnav={vnav} />
<GlideSlope V={V} />
<HSI V={V} nav={nav} />
<HSI V={V} nav={nav} hdg={hdgS} obs={obs} phase={gpsPhase} />
<HdgCrsBoxes V={V} nav={nav} />
<Wind V={V} />
<DataStrip V={V} />
{/* sensor-failure flags (red X) when X-Plane isn't feeding data — the GDU
blanks the affected display and shows a red X, like the real unit */}
{!connected && (
<g>
<RedX x={150} y={113} w={700} h={322} label="AHRS" />
<RedX x={60} y={110} w={84} h={350} />
<RedX x={W - 154} y={110} w={84} h={350} />
<RedX x={W / 2 - 140} y={500} w={280} h={260} label="HDG" />
</g>
)}
</svg>
{nrst && <Nearest values={V} onClose={onCloseNrst} />}
{tmr && <TimerRef values={V} onClose={onCloseTmr} />}
{tmr && <TimerRef values={V} onClose={onCloseTmr} minimums={minimums} onMinimums={onMinimums} />}
{dme && <DmeWindow V={V} onClose={onCloseDme} />}
{alerts && <AlertsWindow V={V} onClose={onCloseAlerts} />}
{tune && <RadioTuner values={V} command={command} radio={tune} onClose={() => setTune(null)} />}
</div>
);
}
@@ -161,7 +216,7 @@ export default function PFD({ values: V, svt = true, inset = false, insetMode, n
// Matches the XPLANE 1000: NAV cyan (active boxed), COM green active /
// cyan-boxed standby, a centre flight-plan cell with DIS/BRG, ⇄ swap arrows.
const SWAP = '⇔';
function RadioBar({ V }) {
function RadioBar({ V, onTune }) {
const swap = (x, y) => <text x={x} y={y} fill="#0ff" fontSize="17" fontFamily="monospace" textAnchor="middle">{SWAP}</text>;
return (
<g fontFamily="monospace">
@@ -170,16 +225,16 @@ function RadioBar({ V }) {
{[330, 560, 690].map((x) => <line key={x} x1={x} y1="2" x2={x} y2="72" stroke="#333" strokeWidth="1.5" />)}
<line x1="0" y1="74" x2={W} y2="74" stroke="#3a3a3a" strokeWidth="2" />
{/* NAV1 / NAV2 (left) */}
{/* NAV1 / NAV2 — per manual: standby LEFT (cyan, boxed/tunable), active RIGHT (white) */}
<text x="14" y="28" fill="#fff" fontSize="14">NAV1</text>
<rect x="58" y="11" width="92" height="22" fill="none" stroke="#0ff" strokeWidth="1.4" />
<text x="146" y="28" fill="#0ff" fontSize="19" textAnchor="end">{navF(V.nav1)}</text>
<text x="146" y="28" fill="#0ff" fontSize="19" textAnchor="end">{navF(V.nav1Sb)}</text>
{swap(176, 28)}
<text x="206" y="28" fill="#fff" fontSize="19">{navF(V.nav1Sb)}</text>
<text x="206" y="28" fill="#fff" fontSize="19">{navF(V.nav1)}</text>
<text x="14" y="60" fill="#fff" fontSize="14">NAV2</text>
<text x="146" y="60" fill="#0ff" fontSize="19" textAnchor="end">{navF(V.nav2)}</text>
<text x="146" y="60" fill="#0ff" fontSize="19" textAnchor="end">{navF(V.nav2Sb)}</text>
{swap(176, 60)}
<text x="206" y="60" fill="#fff" fontSize="19">{navF(V.nav2Sb)}</text>
<text x="206" y="60" fill="#fff" fontSize="19">{navF(V.nav2)}</text>
{/* centre: active leg + DIS/BRG */}
<text x="430" y="26" fill="#e040fb" fontSize="20" textAnchor="middle">{'→'}</text>
@@ -191,15 +246,136 @@ function RadioBar({ V }) {
<text x="676" y="60" fill="#fff" fontSize="14">BRG</text>
{/* COM1 / COM2 (right) */}
<text x="720" y="28" fill="#0f0" fontSize="19">{comF(V.com1)}</text>
{swap(848, 28)}
<rect x="876" y="11" width="100" height="22" fill="none" stroke="#0ff" strokeWidth="1.4" />
<text x="970" y="28" fill="#0ff" fontSize="19" textAnchor="end">{comF(V.com1Sb)}</text>
<text x={W - 4} y="28" fill="#fff" fontSize="13" textAnchor="end">COM1</text>
<text x="720" y="60" fill="#fff" fontSize="19">{comF(V.com2)}</text>
{swap(848, 60)}
<text x="970" y="60" fill="#fff" fontSize="19" textAnchor="end">{comF(V.com2Sb)}</text>
<text x={W - 4} y="60" fill="#fff" fontSize="13" textAnchor="end">COM2</text>
<text x="716" y="28" fill="#0f0" fontSize="19">{comF(V.com1)}</text>
{swap(844, 28)}
<rect x="862" y="12" width="94" height="22" rx="2" fill="none" stroke="#0ff" strokeWidth="1.4" />
<text x="950" y="28" fill="#0ff" fontSize="19" textAnchor="end">{comF(V.com1Sb)}</text>
<text x={W - 6} y="26" fill="#9aa" fontSize="12" textAnchor="end">COM1</text>
<text x="716" y="60" fill="#fff" fontSize="19">{comF(V.com2)}</text>
{swap(844, 60)}
<text x="950" y="60" fill="#fff" fontSize="19" textAnchor="end">{comF(V.com2Sb)}</text>
<text x={W - 6} y="58" fill="#9aa" fontSize="12" textAnchor="end">COM2</text>
{/* tap a radio to open the touch tuner (big hit areas) */}
{onTune && (
<g fill="transparent" style={{ cursor: 'pointer' }}>
<rect x="6" y="8" width="320" height="26" onClick={() => onTune({ id: 'nav1', label: 'NAV1', isCom: false })} />
<rect x="6" y="40" width="320" height="26" onClick={() => onTune({ id: 'nav2', label: 'NAV2', isCom: false })} />
<rect x="700" y="8" width="298" height="26" onClick={() => onTune({ id: 'com1', label: 'COM1', isCom: true })} />
<rect x="700" y="40" width="298" height="26" onClick={() => onTune({ id: 'com2', label: 'COM2', isCom: true })} />
</g>
)}
</g>
);
}
// Red-X failure flag over a blanked instrument region (no valid sensor data).
function RedX({ x, y, w, h, label }) {
return (
<g>
<rect x={x} y={y} width={w} height={h} fill="#0a0d10" opacity="0.82" />
<line x1={x} y1={y} x2={x + w} y2={y + h} stroke="#e01010" strokeWidth="4" />
<line x1={x + w} y1={y} x2={x} y2={y + h} stroke="#e01010" strokeWidth="4" />
{label && (
<text x={x + w / 2} y={y + h / 2 + 6} textAnchor="middle" fill="#ffce46" fontSize="22" fontWeight="bold" fontFamily="monospace">{label}</text>
)}
</g>
);
}
/* ---------------- DME tuning window (DME softkey) ---------------- */
// Mirrors the G1000 DME window: source (NAV1/NAV2/HOLD), frequency, slant
// distance, groundspeed and time-to-station — all from the sim's DME datarefs.
function DmeWindow({ V, onClose }) {
const [src, setSrc] = useState('NAV1');
const isN2 = src === 'NAV2';
const dis = num(isN2 ? V.nav2Dme : V.nav1Dme);
const freq = isN2 ? V.nav2 : V.nav1;
const gs = num(V.groundspeed) * 1.94384;
const min = dis > 0 && gs > 30 ? (dis / gs) * 60 : null;
return (
<div className="pfd-pop dme">
<div className="nrst-head"><span className="nrst-title">DME</span></div>
<div className="pop-grid">
<b>MODE</b><span>{src}</span>
<b>FREQ</b><span>{navF(freq)}</span>
<b>DIS</b><span>{dis > 0 ? `${dis.toFixed(1)} NM` : ' '}</span>
<b>GS</b><span>{Math.round(gs)} KT</span>
<b>TIME</b><span>{min != null ? `${Math.floor(min)}:${String(Math.round((min % 1) * 60)).padStart(2, '0')}` : ' '}</span>
</div>
<div className="pop-tabs">
{['NAV1', 'NAV2', 'HOLD'].map((s) => (
<button key={s} className={src === s ? 'on' : ''} onClick={() => setSrc(s)}>{s}</button>
))}
</div>
</div>
);
}
/* ---------------- ALERTS / messages window (CAUTION softkey) ---------------- */
function AlertsWindow({ V, onClose }) {
const msgs = systemAlerts(V);
return (
<div className="pfd-pop alerts">
<div className="nrst-head"><span className="nrst-title">MESSAGES</span></div>
<div className="alerts-list">
{msgs.length === 0
? <div className="alert-none">NO MESSAGES</div>
: msgs.map((m) => <div key={m.t} className={`alert-row ${m.warn ? 'warn' : 'cau'}`}>{m.t}</div>)}
</div>
</div>
);
}
/* ---------------- AFCS mode annunciation bar ----------------
The green/white mode strip across the top of a real G1000: AP/FD in the
centre, the lateral mode pair on the left (active green, armed white) and the
vertical mode pair on the right (active + reference, armed). Driven by
X-Plane's per-mode _status datarefs (2 = active, 1 = armed), so it mirrors
the sim's autopilot exactly. */
function AFCS({ V }) {
const st = (k) => Math.round(num(V[k]));
const apMode = st('apMode');
const ap = apMode >= 2 || num(V.apEngaged) > 0;
const fd = apMode >= 1 || ap;
// resolve an active (green) + armed (white) label from a priority list
const pick = (entries) => {
let act = '', arm = '';
for (const [lbl, s] of entries) {
if (s === 2 && !act) act = lbl;
else if (s === 1 && !arm) arm = lbl;
}
return { act, arm };
};
const lat = pick([
['GPS', st('gpssStatus')], ['LOC', st('aprStatus')], ['VOR', st('navStatus')],
['BC', st('bcStatus')], ['HDG', st('hdgStatus')],
]);
const vrt = pick([
['GS', st('gsStatus')], ['ALT', st('altStatus')], ['VS', st('vsStatus')],
['FLC', st('flcStatus')], ['VPTH', st('vnavStatus')],
]);
if (!lat.act && fd) lat.act = 'ROL'; // wings-level default
if (!vrt.act && fd) vrt.act = 'PIT'; // pitch-hold default
// reference value beside the active vertical mode
const ref = vrt.act === 'ALT' ? `${Math.round(num(V.apAltBug))}FT`
: vrt.act === 'VS' ? `${Math.round(num(V.apVsBug))}FPM`
: vrt.act === 'FLC' ? `${Math.round(num(V.apSpdBug))}KT` : '';
const cx = W / 2, yb = 98; // baseline
return (
<g fontFamily="monospace" fontSize="17">
<rect x="150" y="78" width={W - 300} height="28" fill="#000" fillOpacity="0.55" />
{/* AP / FD status (centre) */}
<rect x={cx - 30} y="80" width="60" height="24" fill="none" stroke={ap ? '#16c116' : '#555'} strokeWidth="1.4" />
<text x={cx - 16} y={yb} fill={ap ? '#16c116' : '#777'} textAnchor="middle">AP</text>
<text x={cx + 16} y={yb} fill={fd ? '#16c116' : '#777'} textAnchor="middle">FD</text>
{/* lateral: armed (white) then active (green) toward the centre */}
{lat.arm && <text x={cx - 150} y={yb} fill="#fff" textAnchor="middle">{lat.arm}</text>}
{lat.act && <text x={cx - 80} y={yb} fill="#16c116" textAnchor="middle" fontWeight="bold">{lat.act}</text>}
{/* vertical: active (green) + reference, then armed (white) */}
{vrt.act && <text x={cx + 78} y={yb} fill="#16c116" textAnchor="middle" fontWeight="bold">{vrt.act}</text>}
{ref && <text x={cx + 150} y={yb} fill="#16c116" textAnchor="middle">{ref}</text>}
{vrt.arm && <text x={cx + 230} y={yb} fill="#fff" textAnchor="middle">{vrt.arm === 'ALT' ? 'ALTS' : vrt.arm}</text>}
</g>
);
}
@@ -209,16 +385,46 @@ function Attitude({ V, svt }) {
const pitch = num(V.pitch), roll = num(V.roll), slip = num(V.slip);
const fdP = num(V.fdPitch), fdR = num(V.fdRoll);
const cx = W / 2, cy = 270;
const off = pitch * PITCH_PX;
const rollRef = useRef(null), pitchRef = useRef(null), fdRef = useRef(null), bankRef = useRef(null);
// Target attitude (updated every render); a rAF loop eases the displayed
// transforms toward it — decoupled from X-Plane's ~20 Hz samples, so the
// horizon glides instead of stepping. The easing is *time-based* (alpha from
// dt and a time constant TAU), so it feels identical at 30/60/120 fps instead
// of being faster on high-refresh screens.
const tgt = useRef({ p: 0, r: 0, fp: 0, fr: 0 });
tgt.current = { p: pitch, r: roll, fp: pitch - fdP, fr: roll - fdR };
useEffect(() => {
let raf, last = 0;
const d = { ...tgt.current };
const TAU = 0.09; // s — smoothing time constant (bigger = silkier, smaller = snappier)
const loop = (now) => {
const dt = last ? Math.min(0.05, (now - last) / 1000) : 0.016; // clamp tab-switch gaps
last = now;
const k = 1 - Math.exp(-dt / TAU); // frame-rate-independent easing factor
const t = tgt.current;
d.p += (t.p - d.p) * k; d.r += (t.r - d.r) * k;
d.fp += (t.fp - d.fp) * k; d.fr += (t.fr - d.fr) * k;
rollRef.current?.setAttribute('transform', `rotate(${-d.r} ${cx} ${cy})`);
pitchRef.current?.setAttribute('transform', `translate(0 ${d.p * PITCH_PX})`);
bankRef.current?.setAttribute('transform', `rotate(${-d.r} ${cx} ${cy})`);
fdRef.current?.setAttribute('transform', `translate(0 ${d.fp * PITCH_PX}) rotate(${d.fr} ${cx} ${cy})`);
raf = requestAnimationFrame(loop);
};
raf = requestAnimationFrame(loop);
return () => cancelAnimationFrame(raf);
}, []); // eslint-disable-line
return (
<g>
<defs>
<clipPath id="att"><rect x={cx - 290} y={cy - 175} width={580} height={385} /></clipPath>
{/* full-screen attitude, exactly like the SVT box: blue/brown fills the
ENTIRE PFD below the radio bar (behind the tapes, HSI and data strip),
same region as the 3D terrain so both look identical full-screen. */}
<clipPath id="att"><rect x={SVT_BOX.x} y={SVT_BOX.y} width={SVT_BOX.w} height={SVT_BOX.h} /></clipPath>
</defs>
<g clipPath="url(#att)">
<g transform={`rotate(${-roll} ${cx} ${cy})`}>
<g transform={`translate(0 ${off})`}>
<g ref={rollRef}>
<g ref={pitchRef}>
{/* sky/ground only when SVT is off — otherwise the 3D terrain shows */}
{!svt && <rect x={cx - 800} y={cy - 1100} width={1600} height={1100} fill="url(#sky)" />}
{!svt && <rect x={cx - 800} y={cy} width={1600} height={1100} fill="url(#ground)" />}
@@ -226,17 +432,19 @@ function Attitude({ V, svt }) {
{pitchLadder(cx, cy)}
</g>
</g>
{/* flight director command bars (magenta) */}
<g transform={`translate(0 ${(pitch - fdP) * PITCH_PX}) rotate(${roll - fdR} ${cx} ${cy})`}>
<path d={`M${cx - 90} ${cy + 16} L${cx} ${cy - 6} L${cx + 90} ${cy + 16}`}
fill="none" stroke="#e040fb" strokeWidth="6" strokeLinejoin="round" />
{/* flight director command bars magenta filled chevron (single cue) */}
<g ref={fdRef} fill="#e24de0" stroke="#5a1a58" strokeWidth="1">
<polygon points={`${cx - 5},${cy + 13} ${cx - 116},${cy + 45} ${cx - 5},${cy + 26}`} />
<polygon points={`${cx + 5},${cy + 13} ${cx + 116},${cy + 45} ${cx + 5},${cy + 26}`} />
</g>
</g>
{rollArc(cx, cy, roll, slip)}
{/* fixed aircraft reference (yellow) */}
<g stroke="#ffcc00" strokeWidth="6" fill="#111" strokeLinejoin="round">
<path d={`M${cx - 150} ${cy} h60 l16 20 h-76 z`} />
<path d={`M${cx + 150} ${cy} h-60 l-16 20 h76 z`} />
{rollArc(cx, cy, slip, bankRef)}
{/* fixed aircraft reference yellow chevron (single cue) + side wing markers */}
<g fill="#ffce00" stroke="#2a2200" strokeWidth="1">
<polygon points={`${cx - 5},${cy} ${cx - 118},${cy + 30} ${cx - 5},${cy + 13}`} />
<polygon points={`${cx + 5},${cy} ${cx + 118},${cy + 30} ${cx + 5},${cy + 13}`} />
<polygon points={`158,${cy - 6} 192,${cy - 6} 204,${cy} 192,${cy + 6} 158,${cy + 6}`} />
<polygon points={`${W - 158},${cy - 6} ${W - 192},${cy - 6} ${W - 204},${cy} ${W - 192},${cy + 6} ${W - 158},${cy + 6}`} />
</g>
{/* flight path marker (green) — track/AOA based; offset approximated */}
{(() => {
@@ -250,7 +458,6 @@ function Attitude({ V, svt }) {
</g>
);
})()}
{!svt && <rect x={cx - 290} y={cy - 175} width={580} height={385} fill="none" stroke="#000" strokeWidth="2" />}
</g>
);
}
@@ -273,7 +480,7 @@ function pitchLadder(cx, cy) {
return <g>{m}</g>;
}
function rollArc(cx, cy, roll, slip) {
function rollArc(cx, cy, slip, bankRef) {
const r = 165;
const ticks = [-60, -45, -30, -20, -10, 0, 10, 20, 30, 45, 60];
return (
@@ -286,7 +493,7 @@ function rollArc(cx, cy, roll, slip) {
x2={cx + r2 * Math.cos(a)} y2={cy + r2 * Math.sin(a)} stroke="#fff" strokeWidth={big ? 3 : 2} />;
})}
<path d={`M${cx} ${cy - r - 16} l-11 -16 h22 z`} fill="#fff" />
<g transform={`rotate(${-roll} ${cx} ${cy})`}>
<g ref={bankRef}>
<path d={`M${cx} ${cy - r + 2} l-11 18 h22 z`} fill="#ffcc00" />
<rect x={cx - 16 + slip * 7} y={cy - r + 22} width={32} height={9} rx={2} fill="#ffcc00" stroke="#000" />
</g>
@@ -298,15 +505,17 @@ function rollArc(cx, cy, roll, slip) {
// V-speed reference marks for the C172 (KIAS), shown below the tape like the
// XPLANE 1000: Vy=74 (Y), Vx=62 (X), best glide=68 (G).
const VSPEEDS = [{ s: 74, l: 'Y' }, { s: 62, l: 'X' }, { s: 68, l: 'G' }];
function AirspeedTape({ V }) {
const ias = num(V.airspeed), tas = num(V.tas), spdBug = num(V.apSpdBug);
const x = 60, top = 95, h = 350, cy = top + h / 2, px = 3.6;
function AirspeedTape({ V, ias: iasProp }) {
const ias = iasProp != null ? iasProp : num(V.airspeed);
const tas = num(V.tas), spdBug = num(V.apSpdBug);
const x = 60, top = 110, h = 350, cy = top + h / 2, px = 3.6;
const W2 = 84, sx = x + W2 - 7; // colour strip at the right inner edge
const ticks = [];
const lo = Math.floor((ias - 50) / 10) * 10;
for (let s = lo; s <= ias + 50; s += 10) {
if (s < 0) continue;
const y = cy + (ias - s) * px;
if (y < top + 2 || y > top + h - 2) continue; // keep ticks inside the tape
ticks.push(<g key={s}><line x1={x + 48} y1={y} x2={x + 60} y2={y} stroke="#fff" strokeWidth="2" />
<text x={x + 42} y={y + 7} textAnchor="end" fill="#fff" fontSize="22" fontFamily="monospace">{s}</text></g>);
}
@@ -314,49 +523,76 @@ function AirspeedTape({ V }) {
const band = (a, b, color) => <rect x={sx} y={yOf(b)} width={7} height={Math.max(0, yOf(a) - yOf(b))} fill={color} />;
const bugY = Math.max(top, Math.min(top + h, cy + (ias - spdBug) * px));
const valid = ias >= 20;
// magenta airspeed trend vector: 6-second projection from acceleration
// (smoothed dV/dt), exactly like the GDU 1040.
const accRef = useRef({ t: 0, v: ias, a: 0 });
const now = performance.now(), ar = accRef.current;
if (ar.t) { const dt = (now - ar.t) / 1000; if (dt > 0.08) { ar.a = ar.a * 0.7 + ((ias - ar.v) / dt) * 0.3; ar.v = ias; ar.t = now; } }
else { ar.t = now; ar.v = ias; }
const trendY = yOf(ias + ar.a * 6);
return (
<g fontFamily="monospace">
<rect x={x} y={top} width={W2} height={h} fill="#0e1626c8" />
<rect x={x} y={top} width={W2} height={h} fill="#9aa6b3" fillOpacity="0.34" />
{/* V-speed colour strip (white flap arc, green normal, yellow caution, red Vne) */}
{band(33, 85, '#e8e8e8')}
{band(48, 129, '#16c116')}
{band(129, 163, '#e0d000')}
<rect x={sx} y={yOf(180)} width={7} height={Math.max(0, yOf(163) - yOf(180))} fill="#d01010" />
{ticks}
{/* magenta speed trend vector (6-sec projection) */}
{valid && Math.abs(trendY - cy) > 2 && (
<rect x={x + W2 - 4} y={Math.min(cy, trendY)} width="4" height={Math.abs(trendY - cy)} fill="#ff20ff" />
)}
{/* selected-airspeed bug (cyan) */}
<path d={`M${x + W2} ${bugY - 7} h-7 v14 h7 z`} fill="none" stroke="#0ff" strokeWidth="2" />
{/* current-speed readout box (points right toward the tape) */}
<polygon points={`${x + W2},${cy} ${x + W2 - 18},${cy - 22} ${x - 30},${cy - 22} ${x - 30},${cy + 22} ${x + W2 - 18},${cy + 22}`}
fill="#000" stroke="#fff" strokeWidth="2" />
<text x={x + W2 - 22} y={cy + 9} textAnchor="end" fill="#fff" fontSize="30" fontWeight="bold">{valid ? Math.round(ias) : '- - -'}</text>
{/* V-speed reference list below the tape */}
{/* V-speed reference bugs (Vy/Vx/Vg) below the tape — like the real G1000 */}
{VSPEEDS.map((v, i) => (
<g key={v.l}>
<text x={x + 40} y={top + h + 24 + i * 24} textAnchor="end" fill="#0ff" fontSize="18">{v.s}</text>
<rect x={x + 46} y={top + h + 10 + i * 24} width="18" height="18" fill="#0ff" />
<text x={x + 55} y={top + h + 24 + i * 24} textAnchor="middle" fill="#000" fontSize="15" fontWeight="bold">{v.l}</text>
<text x={x + 40} y={top + h + 25 + i * 22} textAnchor="end" fill="#0ff" fontSize="17">{v.s}</text>
<rect x={x + 46} y={top + h + 12 + i * 22} width="17" height="17" fill="#0ff" />
<text x={x + 54} y={top + h + 25 + i * 22} textAnchor="middle" fill="#000" fontSize="14" fontWeight="bold">{v.l}</text>
</g>
))}
{/* TAS box at the very bottom */}
<rect x={x} y={top + h + 84} width={W2} height={26} fill="#000" stroke="#3a3a3a" />
<text x={x + 6} y={top + h + 103} fill="#0ff" fontSize="14">TAS</text>
<text x={x + W2 - 6} y={top + h + 103} textAnchor="end" fill="#fff" fontSize="16">{Math.round(tas)}</text>
{/* TAS box below the V-speeds */}
<rect x={x} y={top + h + 80} width={W2} height={24} fill="#000" stroke="#3a3a3a" />
<text x={x + 6} y={top + h + 97} fill="#0ff" fontSize="13">TAS</text>
<text x={x + W2 - 6} y={top + h + 97} textAnchor="end" fill="#fff" fontSize="15">{Math.round(tas)}</text>
</g>
);
}
/* ---------------- altitude tape + VSI + baro ---------------- */
function AltitudeTape({ V }) {
const alt = num(V.altitude), vs = num(V.vspeed), altBug = num(V.apAltBug), baro = num(V.baro, 29.92);
const x = W - 70 - 84, W2 = 84, top = 95, h = 350, cy = top + h / 2, px = 0.42;
function AltitudeTape({ V, alt: altProp, vs: vsProp, baroHpa = false, minimums, vnav }) {
const alt = altProp != null ? altProp : num(V.altitude);
const vs = vsProp != null ? vsProp : num(V.vspeed);
const altBug = num(V.apAltBug), baro = num(V.baro, 29.92);
const x = W - 70 - 84, W2 = 84, top = 110, h = 350, cy = top + h / 2, px = 0.42;
const ticks = [];
const lo = Math.floor((alt - 420) / 100) * 100;
for (let a = lo; a <= alt + 420; a += 100) {
const y = cy + (alt - a) * px;
if (y < top + 2 || y > top + h - 2) continue; // keep ticks inside the tape
ticks.push(<g key={a}><line x1={x + W2 - 18} y1={y} x2={x + W2 - 4} y2={y} stroke="#fff" strokeWidth="2" />
<text x={x + W2 - 24} y={y + 7} textAnchor="end" fill="#fff" fontSize="19" fontFamily="monospace">{a}</text></g>);
}
const bugY = Math.max(top, Math.min(top + h, cy + (alt - altBug) * px));
// magenta altitude trend vector: 6-second projection from vertical speed
const trendY = Math.max(top, Math.min(top + h, cy + (alt - (alt + vs * 0.1)) * px));
// altitude alerting (GDU 1040): within 1000 ft of the selected altitude the
// box flashes cyan (approaching); within 200 ft it's captured; if it then
// deviates >200 ft the readout turns amber. capRef remembers the capture.
const capRef = useRef(false);
const dAlt = altBug > 0 ? alt - altBug : null;
const adA = dAlt == null ? Infinity : Math.abs(dAlt);
if (adA <= 200) capRef.current = true;
else if (adA > 1000) capRef.current = false;
const approaching = dAlt != null && adA <= 1000 && adA > 200 && !capRef.current;
const deviated = capRef.current && adA > 200;
const selColor = deviated ? '#ffce46' : '#0ff';
// rolling readout: leading hundreds (static) + a two-digit drum that *rolls*
// through 20-ft steps, so you always see the value you're between — exactly
// like the mechanical tens drum on the real GDU 1040.
@@ -369,11 +605,18 @@ function AltitudeTape({ V }) {
const drumX = x + W2 + 4, drumW = 26, drumCx = drumX + drumW / 2;
return (
<g fontFamily="monospace">
{/* selected altitude (cyan) above the tape */}
<rect x={x - 6} y={top - 32} width={W2 + 6} height={26} fill="#000" stroke="#0ff" strokeWidth="1.4" />
<text x={x + W2 - 6} y={top - 13} textAnchor="end" fill="#0ff" fontSize="19">{selStr}</text>
<rect x={x} y={top} width={W2} height={h} fill="#0e1626c8" />
{/* selected altitude above the tape — flashes when approaching, amber on
deviation after capture (altitude alerter) */}
<g className={approaching || deviated ? 'alt-alert' : ''}>
<rect x={x - 6} y={top - 32} width={W2 + 6} height={26} fill={deviated ? '#3a2e00' : '#000'} stroke={selColor} strokeWidth={approaching || deviated ? 2.2 : 1.4} />
<text x={x + W2 - 6} y={top - 13} textAnchor="end" fill={selColor} fontSize="19">{selStr}</text>
</g>
<rect x={x} y={top} width={W2} height={h} fill="#9aa6b3" fillOpacity="0.34" />
{ticks}
{/* magenta altitude trend vector (6-sec projection) on the inner edge */}
{Math.abs(vs) > 30 && Math.abs(trendY - cy) > 2 && (
<rect x={x} y={Math.min(cy, trendY)} width="4" height={Math.abs(trendY - cy)} fill="#ff20ff" />
)}
{/* selected-altitude bug (cyan) on the tape */}
<path d={`M${x} ${bugY - 7} h7 v14 h-7 z`} fill="none" stroke="#0ff" strokeWidth="2" />
{/* current-altitude readout (points left toward the tape): static hundreds
@@ -381,9 +624,9 @@ function AltitudeTape({ V }) {
values are visible at once with the pointer between them (GDU 1040). */}
<defs><clipPath id="altdrum"><rect x={drumX} y={cy - 22} width={drumW} height={44} /></clipPath></defs>
<polygon points={`${x},${cy} ${x + 20},${cy - 24} ${drumX + drumW},${cy - 24} ${drumX + drumW},${cy + 24} ${x + 20},${cy + 24}`}
fill="#000" stroke="#fff" strokeWidth="2" />
<text x={drumX - 3} y={cy + 9} textAnchor="end" fill="#fff" fontSize="27" fontWeight="bold">{hi}</text>
<g clipPath="url(#altdrum)" fill="#fff" fontSize="20" fontWeight="bold">
fill="#000" stroke={deviated ? '#ffce46' : '#fff'} strokeWidth="2" />
<text x={drumX - 3} y={cy + 9} textAnchor="end" fill={deviated ? '#ffce46' : '#fff'} fontSize="27" fontWeight="bold">{hi}</text>
<g clipPath="url(#altdrum)" fill={deviated ? '#ffce46' : '#fff'} fontSize="20" fontWeight="bold">
{[-1, 0, 1, 2].map((k) => {
const v = base + k * STEP;
const s = String(((v % 100) + 100) % 100).padStart(2, '0');
@@ -392,18 +635,52 @@ function AltitudeTape({ V }) {
</g>
{/* baro */}
<rect x={x} y={top + h + 10} width={W2} height={26} fill="#000" stroke="#3a3a3a" />
<text x={x + W2 / 2} y={top + h + 29} textAnchor="middle" fill="#0ff" fontSize="16">{baro.toFixed(2)} IN</text>
{/* VSI to the right */}
<VSI x={x + W2 + 34} cy={cy} h={h} vs={vs} bug={num(V.apVsBug)} />
<text x={x + W2 / 2} y={top + h + 29} textAnchor="middle" fill="#0ff" fontSize="16">{baroHpa ? `${Math.round(baro * 33.8639)} HPA` : `${baro.toFixed(2)} IN`}</text>
{/* barometric minimums (BARO MIN): cyan bug on the tape + readout, amber
"MINIMUMS" annunciation when at/below the decision altitude */}
{minimums?.on && (
<g fontFamily="monospace">
{(() => { const my = Math.max(top, Math.min(top + h, cy + (alt - minimums.ft) * px)); return (
<g><path d={`M${x} ${my} l-9 -6 v12 z`} fill="#19b8e6" /><text x={x - 12} y={my + 5} textAnchor="end" fill="#19b8e6" fontSize="11" fontWeight="bold">B</text></g>
); })()}
<rect x={x} y={top + h + 40} width={W2} height="22" fill="#000" stroke="#19395a" />
<text x={x + 4} y={top + h + 56} fill="#19b8e6" fontSize="12">BARO {minimums.ft}<tspan fill="#0c9" fontSize="10">FT</tspan></text>
{alt > 0 && alt <= minimums.ft && (
<text x={x + W2 / 2} y={cy + 46} textAnchor="middle" fill="#ffce46" fontSize="15" fontWeight="bold">MINIMUMS</text>
)}
</g>
)}
{/* VNAV: magenta flight-plan target altitude (upper-right of the scale, S.110)
+ V DEV chevron on a small deviation scale left of the tape (S.113) */}
{vnav && (
<g fontFamily="monospace">
<text x={x + W2 - 4} y={top + 15} textAnchor="end" fill="#ff20ff" fontSize="14">{vnav.tgtAlt}<tspan fill="#c060c0" fontSize="9">FT</tspan></text>
{/* V DEV scale (left) — only in VNAV, not on an ILS (where the GS shows there) */}
{!isILS(V.nav1) && (() => {
const dy = Math.max(-1, Math.min(1, -vnav.vDev / 300)) * (h / 2 - 26);
return (<g>
<line x1={x - 16} y1={cy - (h / 2 - 26)} x2={x - 16} y2={cy + (h / 2 - 26)} stroke="#6a6a6a" strokeWidth="1.2" />
{[-1, 1].map((k) => <circle key={k} cx={x - 16} cy={cy + k * (h / 2 - 26) / 2} r="2.5" fill="none" stroke="#9aa" strokeWidth="1" />)}
<text x={x - 16} y={cy - (h / 2 - 26) - 4} textAnchor="middle" fill="#c060c0" fontSize="9">V</text>
<polygon points={`${x - 9},${cy + dy} ${x - 23},${cy + dy - 7} ${x - 23},${cy + dy + 7}`} fill="#ff20ff" />
</g>);
})()}
</g>
)}
{/* VSI to the right (+ magenta VS TGT chevron in VNAV) */}
<VSI x={x + W2 + 34} cy={cy} h={h} vs={vs} bug={num(V.apVsBug)} vsTgt={vnav?.vsTgt} />
</g>
);
}
function VSI({ x, cy, h, vs, bug }) {
function VSI({ x, cy, h, vs, bug, vsTgt }) {
const max = 2000, top = cy - h / 2 + 10, bot = cy + h / 2 - 10;
const yOf = (v) => cy - (Math.max(-max, Math.min(max, v)) / max) * (h / 2 - 10);
return (
<g fontFamily="monospace">
<rect x={x} y={top} width="30" height={bot - top} fill="#0e1626a0" />
{vsTgt != null && Math.abs(vsTgt) > 20 && (
<polygon points={`${x + 30},${yOf(vsTgt)} ${x + 42},${yOf(vsTgt) - 7} ${x + 42},${yOf(vsTgt) + 7}`} fill="#ff20ff" />
)}
{[2000, 1000, 0, -1000, -2000].map((v) => (
<g key={v}>
<line x1={x} y1={yOf(v)} x2={x + 8} y2={yOf(v)} stroke="#9aa" strokeWidth="2" />
@@ -419,14 +696,26 @@ function VSI({ x, cy, h, vs, bug }) {
}
/* ---------------- HSI compass rose ---------------- */
function HSI({ V, nav }) {
const hdg = ((num(V.heading) % 360) + 360) % 360;
function HSI({ V, nav, hdg: hdgProp, obs = false, phase = 'ENR' }) {
const hdg = hdgProp != null ? hdgProp : ((num(V.heading) % 360) + 360) % 360;
const bug = num(V.apHdgBug);
// With an active flight-plan leg the CDI follows OUR GPS guidance (desired
// track + cross-track); otherwise it mirrors the sim's nav source.
const crs = nav ? nav.dtk : num(V.obsCrs, 360);
const def = nav ? nav.def : num(V.hsiDef);
const toFrom = nav ? 1 : num(V.hsiToFrom);
// CDI source mirrors the in-sim G1000: 2 = GPS (magenta), 0/1 = VLOC1/2 (green).
// With GPS source + an active leg the CDI follows OUR GPS guidance (desired
// track + cross-track); on VLOC it follows the sim's VOR/LOC needle.
const src = Math.round(num(V.cdiSrc, 2)); // default GPS when unknown
const isGps = src === 2;
// OBS mode (GPS): the course is the pilot-set OBS course (CRS knob); cross-track
// is measured against the OBS radial through the active waypoint. Sequencing is
// suspended (the leg does not auto-advance).
const obsActive = obs && isGps && !!nav;
const useNav = isGps && !!nav && !obsActive;
const C = isGps ? '#e040fb' : '#00d800'; // magenta GPS / green VLOC
const srcLabel = obsActive ? 'OBS' : isGps ? 'GPS' : (src === 1 ? 'VLOC2' : 'VLOC1');
const obsCrs = num(V.obsCrs, 360);
const obsDef = obsActive ? Math.max(-2.5, Math.min(2.5, -(nav.dist * Math.sin((nav.brg - obsCrs) * D2R)) / 1.0)) : 0;
const crs = obsActive ? obsCrs : useNav ? nav.dtk : obsCrs;
const def = obsActive ? obsDef : useNav ? nav.def : num(V.hsiDef);
const toFrom = (useNav || obsActive) ? 1 : num(V.hsiToFrom);
const cx = W / 2, cy = 630, r = 130;
const ticks = [];
@@ -458,30 +747,72 @@ function HSI({ V, nav }) {
<g transform={`rotate(${bugA} ${cx} ${cy})`}>
<path d={`M${cx} ${cy - r} l-10 -12 h6 v12 h8 v-12 h6 z`} fill="#0ff" stroke="#000" />
</g>
{/* GPS source label */}
<text x={cx - 56} y={cy - 10} textAnchor="middle" fill="#e040fb" fontSize="15">GPS</text>
<text x={cx + 56} y={cy - 10} textAnchor="middle" fill="#e040fb" fontSize="15">ENR</text>
{/* course pointer + CDI (magenta = GPS source) */}
{/* CDI source label (GPS magenta / VLOC green) */}
<text x={cx - 56} y={cy - 10} textAnchor="middle" fill={C} fontSize="15">{srcLabel}</text>
{isGps && <text x={cx + 56} y={cy - 10} textAnchor="middle" fill={C} fontSize="15">{phase}</text>}
{/* course pointer + CDI */}
<g transform={`rotate(${crsA} ${cx} ${cy})`}>
<line x1={cx} y1={cy - r + 18} x2={cx} y2={cy - 40} stroke="#e040fb" strokeWidth="4" />
<polygon points={`${cx},${cy - r + 4} ${cx - 9},${cy - r + 22} ${cx + 9},${cy - r + 22}`} fill="#e040fb" />
<line x1={cx} y1={cy + 40} x2={cx} y2={cy + r - 18} stroke="#e040fb" strokeWidth="4" />
<line x1={cx} y1={cy - r + 18} x2={cx} y2={cy - 40} stroke={C} strokeWidth="4" />
<polygon points={`${cx},${cy - r + 4} ${cx - 9},${cy - r + 22} ${cx + 9},${cy - r + 22}`} fill={C} />
<line x1={cx} y1={cy + 40} x2={cx} y2={cy + r - 18} stroke={C} strokeWidth="4" />
{/* CDI deviation bar */}
<line x1={cx + defPx} y1={cy - 42} x2={cx + defPx} y2={cy + 42} stroke="#e040fb" strokeWidth="5" />
<line x1={cx + defPx} y1={cy - 42} x2={cx + defPx} y2={cy + 42} stroke={C} strokeWidth="5" />
{[-2, -1, 1, 2].map((d) => <circle key={d} cx={cx + d * 26} cy={cy} r={3.5} fill="none" stroke="#fff" strokeWidth="1.5" />)}
{toFrom > 0 && <polygon points={toFrom === 1
? `${cx},${cy - 60} ${cx - 9},${cy - 46} ${cx + 9},${cy - 46}`
: `${cx},${cy + 60} ${cx - 9},${cy + 46} ${cx + 9},${cy + 46}`} fill="#e040fb" />}
: `${cx},${cy + 60} ${cx - 9},${cy + 46} ${cx + 9},${cy + 46}`} fill={C} />}
</g>
{/* cyan bearing pointer to the active flight-plan waypoint (BRG) */}
{/* bearing pointers — BRG1 = NAV1 (solid single needle), BRG2 = GPS active
leg (hollow double needle). Both track the station/waypoint via the
sim's bearing datarefs, so they stay in sync with the 3-D G1000. */}
{num(V.nav1Dme) > 0 && (
<g transform={`rotate(${num(V.nav1Brg) - hdg} ${cx} ${cy})`} stroke="#0ff" fill="#0ff">
<polygon points={`${cx},${cy - r + 2} ${cx - 7},${cy - r + 20} ${cx + 7},${cy - r + 20}`} />
<line x1={cx} y1={cy - r + 20} x2={cx} y2={cy - 36} strokeWidth="3" />
<line x1={cx} y1={cy + 36} x2={cx} y2={cy + r - 6} strokeWidth="3" />
</g>
)}
{nav && (
<g transform={`rotate(${nav.brg - hdg} ${cx} ${cy})`}>
<line x1={cx} y1={cy - r + 2} x2={cx} y2={cy - r + 30} stroke="#0ff" strokeWidth="3" />
<polygon points={`${cx},${cy - r - 6} ${cx - 8},${cy - r + 12} ${cx + 8},${cy - r + 12}`} fill="none" stroke="#0ff" strokeWidth="2.5" />
<line x1={cx} y1={cy + r - 30} x2={cx} y2={cy + r - 2} stroke="#0ff" strokeWidth="3" />
<g transform={`rotate(${nav.brg - hdg} ${cx} ${cy})`} stroke="#0ff" fill="none" strokeWidth="2.5">
<polygon points={`${cx},${cy - r + 2} ${cx - 8},${cy - r + 22} ${cx + 8},${cy - r + 22}`} />
<line x1={cx - 3} y1={cy - r + 22} x2={cx - 3} y2={cy - 36} />
<line x1={cx + 3} y1={cy - r + 22} x2={cx + 3} y2={cy - 36} />
<line x1={cx - 3} y1={cy + 36} x2={cx - 3} y2={cy + r - 6} />
<line x1={cx + 3} y1={cy + 36} x2={cx + 3} y2={cy + r - 6} />
</g>
)}
<rect x={cx - 7} y={cy - 7} width={14} height={14} fill="#ffcc00" stroke="#000" strokeWidth="2" />
{/* BRG info windows (lower corners): source + DME distance */}
<BrgWindow x={150} y={cy + 36} n={1} src="NAV1" dist={num(V.nav1Dme)} solid />
<BrgWindow x={W - 150} y={cy + 36} n={2} src="GPS" dist={nav ? nav.dist : 0} anchor="end" />
</g>
);
}
// One BRG pointer info window (icon + source + DME distance), like the G1000's
// lower-corner bearing readouts.
function BrgWindow({ x, y, n, src, dist, solid = false, anchor = 'start' }) {
if (!(dist > 0) && src !== 'GPS') return null;
return (
<g fontFamily="monospace" textAnchor={anchor}>
<text x={x} y={y} fill="#0ff" fontSize="13">BRG{n}</text>
<text x={x} y={y + 19} fill="#fff" fontSize="15">{src}</text>
<text x={x} y={y + 38} fill="#0ff" fontSize="15">{dist > 0 ? `${dist.toFixed(1)}NM` : ' '}</text>
</g>
);
}
// Marker-beacon annunciator (OM cyan / MM amber / IM white), upper-left.
function Marker({ V }) {
const im = num(V.mkrInner), mm = num(V.mkrMiddle), om = num(V.mkrOuter);
const m = im > 0 ? { t: 'IM', c: '#000', bg: '#fff' }
: mm > 0 ? { t: 'MM', c: '#000', bg: '#e0a000' }
: om > 0 ? { t: 'OM', c: '#fff', bg: '#19d3ff' } : null;
if (!m) return null;
return (
<g>
<rect x={156} y={118} width={42} height={26} rx={13} fill={m.bg} stroke="#000" strokeWidth="1.5" />
<text x={177} y={137} textAnchor="middle" fill={m.c} fontSize="16" fontWeight="bold" fontFamily="monospace">{m.t}</text>
</g>
);
}
@@ -562,6 +893,35 @@ function HdgCrsBoxes({ V, nav }) {
);
}
/* ---------------- wind box (lower-left), like the real G1000 ---------------- */
function Wind({ V }) {
const spd = Math.round(num(V.windSpd));
const dir = ((Math.round(num(V.windDir)) % 360) + 360) % 360;
const hdg = num(V.heading);
const bx = 14, by = 636, bw = 128, bh = 92, cxw = bx + 30, cyw = by + 52;
// arrow points the way the wind blows relative to the nose (from dir → +180)
const rot = dir + 180 - hdg;
return (
<g fontFamily="monospace">
<rect x={bx} y={by} width={bw} height={bh} rx="4" fill="#000a" stroke="#3a3a3a" />
<text x={bx + bw / 2} y={by + 17} textAnchor="middle" fill="#9aa" fontSize="12">WIND</text>
{spd >= 1 ? (
<>
<circle cx={cxw} cy={cyw} r="18" fill="none" stroke="#5a5f66" strokeWidth="1.5" />
<g transform={`rotate(${rot} ${cxw} ${cyw})`} stroke="#fff" strokeWidth="3" fill="#fff">
<line x1={cxw} y1={cyw + 16} x2={cxw} y2={cyw - 14} />
<polygon points={`${cxw},${cyw - 20} ${cxw - 6},${cyw - 8} ${cxw + 6},${cyw - 8}`} stroke="none" />
</g>
<text x={bx + 58} y={by + 46} fill="#fff" fontSize="20">{String(dir).padStart(3, '0')}°</text>
<text x={bx + 58} y={by + 72} fill="#fff" fontSize="20">{spd}<tspan fill="#9aa" fontSize="13">KT</tspan></text>
</>
) : (
<text x={bx + bw / 2} y={by + 56} textAnchor="middle" fill="#6f808d" fontSize="14">NO WIND</text>
)}
</g>
);
}
/* ---------------- bottom data line: OAT / ISA / XPDR / LCL ---------------- */
function DataStrip({ V }) {
const oatC = num(V.oat);
@@ -574,7 +934,6 @@ function DataStrip({ V }) {
const lcl = `${String(now.getHours()).padStart(2, '0')}:${String(now.getMinutes()).padStart(2, '0')}:${String(now.getSeconds()).padStart(2, '0')}`;
return (
<g fontFamily="monospace" fontSize="17">
<line x1="0" y1="730" x2={W} y2="730" stroke="#3a3a3a" strokeWidth="1.5" />
{/* OAT + ISA (left) */}
<rect x="14" y="742" width="118" height="26" fill="#000" stroke="#3a3a3a" />
<text x="22" y="761" fill="#fff">OAT {oatF}°F</text>
+51 -13
View File
@@ -21,9 +21,11 @@ export default function Proc({ xp, onClose }) {
const [procs, setProcs] = useState(null);
const [err, setErr] = useState('');
const [cat, setCat] = useState('approach');
const [view, setView] = useState('menu'); // 'menu' (PDF action list) | 'pick'
const [selProc, setSelProc] = useState(null); // { name, transitions }
const [selTrans, setSelTrans] = useState('');
const [legs, setLegs] = useState([]);
const [note, setNote] = useState('');
// Fetch the procedure summary whenever the airport changes.
useEffect(() => {
@@ -53,18 +55,61 @@ export default function Proc({ xp, onClose }) {
const load = () => {
if (!legs.length) return;
const existing = wps.slice();
// Departures go to the front, arrivals/approaches to the end.
const merged = cat === 'departure' ? [...legs, ...existing] : [...existing, ...legs];
// Approaches carry the missed-approach segment too (server-tagged via `seg`):
// flag approach legs `appr` and missed legs `missed` so the FMS can activate
// each on demand. Departures go to the front, arrivals/approaches to the end.
const tagged = cat === 'approach'
? legs.map((l) => (l.seg === 'missed' ? { ...l, missed: true } : { ...l, appr: true }))
: legs;
const merged = cat === 'departure' ? [...tagged, ...existing] : [...existing, ...tagged];
fp.set({ name: 'ACTIVE', waypoints: merged, activeLeg: cat === 'departure' ? 1 : existing.length || 1 });
onClose();
};
// Activate a segment already loaded in the plan, like the real PROC menu.
// setActive(i) makes the leg ENDING at waypoint i the magenta (active) leg.
const activate = (find, label) => {
const i = find(flightPlan?.waypoints || []);
if (i > 0) { fp.setActive(i); onClose(); }
else setNote(`Kein ${label} im Flugplan — erst SELECT APPROACH → LOAD`);
};
const firstIdx = (pred) => (ws) => ws.findIndex(pred);
const lastIdx = (pred) => (ws) => { for (let i = ws.length - 1; i >= 0; i--) if (pred(ws[i])) return i; return -1; };
const catLabel = CATS.find((c) => c.id === cat).label;
// The PDF's action menu. SELECT opens our picker for that category;
// ACTIVATE are shown for authenticity (armed-procedure actions).
if (view === 'menu') {
const item = (label, onClick, sel) => (
<button className={`proc-menu-i ${sel ? 'sel' : ''}`} onClick={onClick}>{label}</button>
);
const sel = (c) => { setCat(c); setSelProc(null); setSelTrans(''); setView('pick'); };
return (
<div className="gwin-backdrop" onClick={onClose}>
<div className="dlg proc menu" onClick={(e) => e.stopPropagation()}>
<div className="dlg-head">PROCEDURES</div>
<div className="proc-menu">
{item('ACTIVATE VECTOR-TO-FINAL', () => activate(lastIdx((w) => w.appr), 'Approach'))}
{item('ACTIVATE APPROACH', () => activate(firstIdx((w) => w.appr), 'Approach'))}
{item('ACTIVATE MISSED APPROACH', () => activate(firstIdx((w) => w.missed), 'Missed Approach'))}
{item('SELECT APPROACH', () => sel('approach'), true)}
{item('SELECT ARRIVAL', () => sel('arrival'))}
{item('SELECT DEPARTURE', () => sel('departure'))}
</div>
{note && <div className="proc-note">{note}</div>}
</div>
</div>
);
}
return (
<div className="dlg-backdrop" onClick={onClose}>
<div className="gwin-backdrop" onClick={onClose}>
<div className="dlg proc" onClick={(e) => e.stopPropagation()}>
<div className="dlg-head">PROCEDURES</div>
<div className="dlg-head">{catLabel}</div>
<div className="proc-body">
<div className="proc-apt">
<button className="proc-back" onClick={() => setView('menu')}></button>
<label>APT</label>
<input value={query} onChange={(e) => setQuery(e.target.value.toUpperCase())}
onKeyDown={(e) => e.key === 'Enter' && setIcao(query)}
@@ -73,13 +118,6 @@ export default function Proc({ xp, onClose }) {
</div>
{err && <div className="proc-err">{err}</div>}
<div className="proc-tabs">
{CATS.map((c) => (
<button key={c.id} className={cat === c.id ? 'on' : ''}
onClick={() => { setCat(c.id); setSelProc(null); setSelTrans(''); }}>{c.label}</button>
))}
</div>
<div className="proc-cols">
<div className="proc-list">
<div className="proc-coltitle">{procs ? `${catList.length}` : '—'} PROC</div>
@@ -99,8 +137,8 @@ export default function Proc({ xp, onClose }) {
<div className="proc-preview">
<div className="proc-coltitle">{legs.length} FIXES</div>
{legs.map((l, i) => (
<div key={l.id + i} className="proc-leg">
<b>{l.id}</b>{l.alt ? <u>{l.alt}ft</u> : null}
<div key={l.id + i} className={`proc-leg ${l.seg === 'missed' ? 'missed' : ''}`}>
<b>{l.id}</b>{l.alt ? <u>{l.alt}ft</u> : null}{l.seg === 'missed' ? <i className="missed-tag">MA</i> : null}
</div>
))}
</div>
+48
View File
@@ -0,0 +1,48 @@
import React from 'react';
import { num } from '../api/useXplane.js';
// Touch-friendly radio tuner, styled like our KAP 140 (green LCD) + the desktop
// launcher (macOS-dark chrome). Tunes the STANDBY frequency and swaps it active
// via X-Plane's own per-radio commands (no unit-sensitive frequency writes).
const fmt = (v, isCom) => (num(v) / 100).toFixed(isCom ? 3 : 2);
export default function RadioTuner({ values, command, radio, onClose }) {
const { id, label, isCom } = radio;
const sb = values[`${id}Sb`];
const act = values[id];
const cmd = (s) => command(`${id}${s}`);
return (
<div className="dlg-backdrop" onClick={onClose}>
<div className="rtuner" onClick={(e) => e.stopPropagation()}>
<div className="rt-head">
<span className="rt-title">{label}</span>
<span className="rt-kind">{isCom ? 'COM' : 'NAV'}</span>
</div>
<div className="rt-lcd">
<div className="rt-f"><span>ACTIVE</span><b className="act">{fmt(act, isCom)}</b></div>
<button className="rt-swap" onClick={() => cmd('Swap')} title="Aktiv ↔ Standby"></button>
<div className="rt-f right"><span>STANDBY</span><b className="sby">{fmt(sb, isCom)}</b></div>
</div>
<div className="rt-tune">
<div className="rt-row">
<span className="rt-unit">MHz</span>
<button className="rt-step" onClick={() => cmd('CoarseDown')}></button>
<button className="rt-step" onClick={() => cmd('CoarseUp')}>+</button>
</div>
<div className="rt-row">
<span className="rt-unit">kHz</span>
<button className="rt-step" onClick={() => cmd('FineDown')}></button>
<button className="rt-step" onClick={() => cmd('FineUp')}>+</button>
</div>
</div>
<div className="rt-actions">
<button className="rt-btn primary" onClick={() => cmd('Swap')}> Auf Aktiv</button>
<button className="rt-btn" onClick={onClose}>Schließen</button>
</div>
</div>
</div>
);
}
+23 -3
View File
@@ -63,6 +63,26 @@ function runwayGeoJSON(list) {
return { type: 'FeatureCollection', features: feats };
}
// Place the synthetic horizon exactly where the PFD attitude horizon sits, so
// the 3D terrain and the 2D attitude agree. The attitude horizon is at 28% of
// the SVT box at level flight and moves PITCH_PX (9 px) per degree within the
// 706-px-tall box; the canvas is scaled 1.5× about that 28% line (to cover the
// corners when banked). We invert MapLibre's perspective to find the camera
// pitch that lands the flat horizon there. With the default vertical FOV of
// 36.87°, the focal-length/height ratio is 0.5/tan(fov/2) = 1.5, independent of
// resolution. Screen offset of the horizon above centre = f·tan(90°pitch).
const CANVAS_SCALE = 1.5; // matches .svt-canvas CSS transform scale
const FOV_FH = 1.5; // 0.5 / tan(fov/2), fov = 36.87° (MapLibre default)
const HORIZON0 = (270 - 74) / 706; // attitude horizon as a fraction of the SVT box (0.2776)
const PX_PER_DEG = 9 / 706; // PITCH_PX / box height displayed horizon travel per °
function cameraPitchForAircraft(aircraftPitchDeg) {
const dispFrac = HORIZON0 + PX_PER_DEG * aircraftPitchDeg; // where the horizon must appear
const rawFrac = HORIZON0 + (dispFrac - HORIZON0) / CANVAS_SCALE; // undo the 1.5× canvas scale
const t = (0.5 - rawFrac) / FOV_FH; // = tan(90°pitch)
const pitch = 90 - (Math.atan(t) * 180) / Math.PI;
return Math.max(60, Math.min(85, pitch));
}
export default function SVT({ values }) {
const elRef = useRef(null);
const mapRef = useRef(null);
@@ -77,9 +97,9 @@ export default function SVT({ values }) {
style: STYLE,
center: [num(values.lon, -122.31), num(values.lat, 47.45)],
zoom: 11.5,
pitch: 72,
pitch: cameraPitchForAircraft(num(values.pitch)),
bearing: num(values.heading),
maxPitch: 76, // lower max pitch = nearer horizon = less distant terrain
maxPitch: 85, // horizon placement needs ~82° at level, more when pitched up
pixelRatio: 1, // don't render at 2× on retina big perf/bandwidth win
renderWorldCopies: false,
maxTileCacheSize: 40,
@@ -154,7 +174,7 @@ export default function SVT({ values }) {
map.jumpTo({
center: [num(v.lon, -122.31), num(v.lat, 47.45)],
bearing: num(v.heading),
pitch: Math.max(58, Math.min(76, 72 + num(v.pitch))),
pitch: cameraPitchForAircraft(num(v.pitch)),
zoom,
});
updateTerrainAwareness(num(v.altitude, 5500));
+5 -4
View File
@@ -19,14 +19,16 @@ function fmt(sec) {
return h > 0 ? `${pad(h)}:${pad(m)}:${pad(ss)}` : `${pad(m)}:${pad(ss)}`;
}
export default function TimerRef({ values, onClose }) {
export default function TimerRef({ values, onClose, minimums = { on: false, ft: 500 }, onMinimums }) {
const [dir, setDir] = useState('up'); // 'up' | 'dn'
const [running, setRunning] = useState(false);
const [elapsed, setElapsed] = useState(0); // seconds
const [target, setTarget] = useState(300); // count-down start (s)
const [vbugs, setVbugs] = useState({}); // key -> bool (shown on tape, future)
const [minsOn, setMinsOn] = useState(false);
const [mins, setMins] = useState(500); // baro minimums (ft)
// Minimums are lifted to App so the PFD altimeter can show the BARO MIN bug.
const minsOn = minimums.on, mins = minimums.ft;
const setMins = (fn) => onMinimums && onMinimums((m) => ({ ...m, ft: Math.max(0, typeof fn === 'function' ? fn(m.ft) : fn) }));
const setMinsOn = (fn) => onMinimums && onMinimums((m) => ({ ...m, on: typeof fn === 'function' ? fn(m.on) : fn }));
const tickRef = useRef(null);
useEffect(() => {
@@ -46,7 +48,6 @@ export default function TimerRef({ values, onClose }) {
<div className="tmr-window">
<div className="nrst-head">
<span className="nrst-title">TIMER / REFERENCES</span>
{onClose && <button className="nrst-x" onClick={onClose}></button>}
</div>
<div className="tmr-body">
<div className="tmr-clock">{fmt(shown)}</div>
+18 -12
View File
@@ -1,5 +1,7 @@
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
// coordinator, heading indicator, vertical speed round steam gauges driven by
@@ -54,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) => {
@@ -79,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 (
@@ -117,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;
@@ -140,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 (
@@ -166,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;
@@ -192,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; // -2000110°, 0270°, +2000430°(=70°)
return (
@@ -232,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')}
@@ -248,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 (
@@ -276,10 +279,12 @@ function Clock({ V }) {
);
}
export default function VFR({ values: V }) {
export default function VFR({ xp }) {
const V = xp.values;
const fuelL = arr0(V.fuelQty, 0) / KG_GAL, fuelR = (Array.isArray(V.fuelQty) ? num(V.fuelQty[1]) : 0) / KG_GAL;
const oilF = arr0(V.oilTemp) * 9 / 5 + 32, oilP = arr0(V.oilPress);
const egtF = arr0(V.egt) * 9 / 5 + 32, ffGph = (arr0(V.fuelFlow) * 3600) / KG_GAL;
const oilT = arr0(V.oilTemp), egtT = arr0(V.egt);
const oilF = oilT > 150 ? oilT : oilT * 9 / 5 + 32, oilP = arr0(V.oilPress);
const egtF = egtT > 900 ? egtT : egtT * 9 / 5 + 32, ffGph = (arr0(V.fuelFlow) * 3600) / KG_GAL;
const amps = arr0(V.amps);
return (
<div className="vfr-panel">
@@ -290,13 +295,14 @@ export default function VFR({ values: V }) {
<Dual title="OIL" l={{ value: oilF, min: 75, max: 250, green: [100, 245], tag: '°F' }} r={{ value: oilP, min: 0, max: 115, green: [25, 100], tag: 'PSI' }} />
<Dual title="EGT · FF" l={{ value: egtF, min: 800, max: 1650, tag: 'EGT' }} r={{ value: ffGph, min: 0, max: 20, green: [0, 17], tag: 'GPH' }} />
<Dual title="VAC · AMP" l={{ value: 5, min: 0, max: 10, green: [4.5, 5.5], tag: 'SUC' }} r={{ value: amps, min: -60, max: 60, green: [0, 60], tag: 'AMP' }} />
<div className="vfr-tach"><Tach V={V} /></div>
</div>
<div className="vfr-main">
<div className="vfr-grid">
<ASI V={V} /><AI V={V} /><ALT V={V} />
<TC V={V} /><HI V={V} /><VSI V={V} />
</div>
<div className="vfr-tach"><Tach V={V} /></div>
<div className="vfr-ap"><KAP140 xp={xp} /></div>
</div>
</div>
</div>
+306 -49
View File
@@ -6,15 +6,17 @@
/* App chrome (everything that is NOT a G1000 instrument): same clean
macOS-dark look as the desktop launcher. */
--ui-font: 'Inter', -apple-system, BlinkMacSystemFont, 'SF Pro Text', 'Segoe UI', system-ui, sans-serif;
--c-bg: #1c1c1e;
--c-surface: #2c2c2e;
--c-fill: #3a3a3c;
--c-line: #48484a;
--c-line-soft: #38383a;
--c-txt: #ffffff;
--c-txt2: #ebebf5;
--c-mut: #8e8e93;
--c-green: #30d158;
/* monochrome chrome palette (191919 / 0f0f0f / f0f0f0) — no colour accent */
--c-bg: #0f0f0f;
--c-surface: #191919;
--c-fill: #232323;
--c-line: #3a3a3a;
--c-line-soft: #2a2a2a;
--c-txt: #f0f0f0;
--c-txt2: #e0e0e0;
--c-mut: #8a8a8a;
--c-on: #f0f0f0; /* active/primary highlight (inverted) */
--c-green: #30d158; /* kept only for the green LCD/display look */
--c-amber: #ffd60a;
--c-red: #ff453a;
color-scheme: dark;
@@ -54,7 +56,7 @@ body {
}
.sb-top:hover { background: #34343a; }
.brand { font-weight: 700; font-size: 17px; letter-spacing: .3px; white-space: nowrap; }
.brand span { color: var(--c-green); font-weight: 500; }
.brand span { color: var(--c-mut); font-weight: 500; }
.sb-chev { color: var(--c-mut); font-size: 12px; }
.app.nav-narrow .brand span { display: none; }
.app.nav-narrow .sb-chev { display: none; }
@@ -70,7 +72,7 @@ body {
transition: background .12s, color .12s;
}
.snav-i:hover { background: var(--c-surface); color: var(--c-txt2); }
.snav-i.active { background: rgba(48,209,88,.16); color: var(--c-green); border-color: rgba(48,209,88,.35); }
.snav-i.active { background: rgba(255,255,255,.09); color: var(--c-txt); border-color: #444; }
.snav-ic { flex: 0 0 22px; }
.snav-lbl { white-space: nowrap; }
.app.nav-narrow .snav-lbl { display: none; }
@@ -100,6 +102,39 @@ body {
.vfr-gauge svg, .vfr-sg svg { width: 100%; height: auto; filter: drop-shadow(0 4px 12px rgba(0,0,0,.55)); }
.vfr-name, .vfr-sname { font-family: var(--ui-font); letter-spacing: 1.2px; color: #c9d0d7; font-weight: 600; }
.vfr-name { font-size: 11px; } .vfr-sname { font-size: 9px; }
.vfr-ap { display: flex; justify-content: center; margin-top: clamp(8px, 1.5vw, 18px); }
/* KAP 140 autopilot (steam-gauge C172) */
.kap140 { display: flex; align-items: center; gap: 12px; background: linear-gradient(#2a2c30, #161719);
border: 1px solid #0a0a0a; border-top: 1px solid #4a4d52; border-radius: 10px; padding: 11px 14px; font-family: var(--ui-font);
box-shadow: 0 8px 24px rgba(0,0,0,.6), inset 0 1px 0 rgba(255,255,255,.06); }
.kap-brand { color: #8893a0; font-size: 9px; font-weight: 700; letter-spacing: 1px; writing-mode: vertical-rl; transform: rotate(180deg); }
.kap-lcd { background: #06160b; border: 1px solid #0a4d24; border-radius: 4px; padding: 7px 11px; min-width: 168px;
box-shadow: inset 0 0 16px rgba(0,90,35,.5); font-family: 'Saira Semi Condensed', monospace; }
.kap-l1 { display: flex; gap: 12px; align-items: center; }
.kap-l1 .an { color: #0c3a1e; font-weight: 700; font-size: 14px; letter-spacing: 1px; }
.kap-l1 .an.on { color: #3bff6e; text-shadow: 0 0 8px rgba(59,255,110,.5); }
.kap-l2 { display: flex; gap: 14px; align-items: baseline; margin-top: 3px; color: #3bff6e; }
.kap-l2 .big { font-size: 22px; font-weight: 700; }
.kap-l2 .u { font-size: 10px; color: #1f9d52; margin-left: 2px; }
.kap-l2 .vs { font-size: 14px; }
.kap-keys { display: flex; gap: 7px; align-items: stretch; }
/* physical, G1000-style buttons */
.kap-btn { background: linear-gradient(#3b3e44, #23262b); color: #eef2f6; border: 1px solid #08090b; border-top: 1px solid #5c6168;
border-radius: 7px; padding: 13px 11px; font-family: var(--ui-font); font-size: 12px; font-weight: 700; letter-spacing: .3px; cursor: pointer; min-width: 44px;
box-shadow: 0 2px 4px rgba(0,0,0,.55), inset 0 1px 0 rgba(255,255,255,.10); }
.kap-btn:hover { background: linear-gradient(#454951, #2a2d33); }
.kap-btn:active { transform: translateY(1px); background: linear-gradient(#1a8f44, #136b32); color: #fff; box-shadow: inset 0 2px 5px rgba(0,0,0,.6); }
.kap-btn.sm { padding: 6px 9px; font-size: 10px; min-width: 0; }
.kap-updn { display: flex; flex-direction: column; gap: 4px; }
/* clickable rotary: top half = up, bottom half = down */
.kap-knob { display: flex; flex-direction: column; align-items: center; gap: 3px; }
.kap-dial { position: relative; width: 50px; height: 50px; border-radius: 50%; cursor: pointer;
background: radial-gradient(circle at 38% 30%, #4e535b, #14161a 72%); border: 1px solid #08090b;
box-shadow: 0 2px 6px rgba(0,0,0,.6), inset 0 1px 0 rgba(255,255,255,.14);
display: flex; flex-direction: column; align-items: center; justify-content: space-between; padding: 3px 0; }
.kap-dial .kdir { color: #aeb6bf; font-size: 11px; line-height: 1; user-select: none; }
.kap-dial:hover .kdir { color: #fff; }
.kap-knoblbl { color: #8893a0; font-size: 9px; font-weight: 700; letter-spacing: 1px; }
.vfr-clock { background: #0c0d0f; border: 1px solid #2a2f36; border-radius: 6px; padding: 8px 10px; display: flex; flex-direction: column; gap: 4px; }
.vc-row { display: flex; align-items: baseline; justify-content: space-between; gap: 10px; }
.vc-row b { font-family: 'Saira Condensed', monospace; color: #46e0c0; font-size: 18px; }
@@ -124,16 +159,36 @@ body {
.pfd-inset .mapwrap, .pfd-inset .leaflet-host { width: 100%; height: 100%; }
.mapwrap.inset .leaflet-control-container { display: none; }
/* NRST (nearest) window — pops over the right side of the PFD */
/* G1000 windows: flat opaque rectangles embedded in the display no shadow,
no rounded corners, thin light border. They look part of the screen. */
.nrst-window {
position: absolute; z-index: 4; top: 9%; right: 1.5%; width: 41%; max-width: 440px;
background: rgba(8, 10, 12, 0.94); border: 1px solid #4a5560; border-radius: 3px;
color: #fff; font-family: 'Roboto Mono', monospace; box-shadow: 0 4px 18px rgba(0,0,0,0.6);
position: absolute; z-index: 4; right: var(--gwin-right, 4%); bottom: var(--gwin-bottom, 6%); top: auto;
width: var(--gwin-maxw, 290px); max-height: var(--gwin-maxh, 44%);
display: flex; flex-direction: column;
background: #05080b; border: 1px solid #7e8a94; border-radius: 0;
color: #fff; font-family: 'Roboto Mono', monospace;
}
.nrst-list { flex: 1; }
/* NEAREST AIRPORTS: two-line entries like the real GDU (ident/brg/dis/approach
then com-type/freq/runway-length) */
.apt-entry { padding: 4px 8px 5px; border-bottom: 1px solid #161b20; }
.apt-l1 { display: grid; grid-template-columns: 1fr auto auto auto; align-items: baseline; column-gap: 8px; }
.apt-l2 { display: grid; grid-template-columns: auto 1fr auto auto; align-items: baseline; column-gap: 6px; margin-top: 1px; }
.apt-id { color: #36d2ff; font-size: 16px; font-weight: bold; justify-self: start; }
.apt-id.cur { background: #19b8e6; color: #042230; padding: 0 4px; border-radius: 1px; }
.apt-brg, .apt-dis { color: #fff; font-size: 14px; text-align: right; }
.apt-dis u { color: #6f808d; font-size: 9px; text-decoration: none; margin-left: 1px; }
.apt-app { color: #fff; font-size: 12px; min-width: 30px; text-align: right; }
.apt-app.ils { color: #16d24a; }
.apt-comlbl { color: #6f808d; font-size: 11px; }
.apt-com { color: #fff; font-size: 13px; }
.apt-rwlbl { color: #6f808d; font-size: 11px; }
.apt-rw { color: #fff; font-size: 13px; text-align: right; }
.nrst-head { display: flex; align-items: center; gap: 8px; padding: 5px 8px; background: #11161b; border-bottom: 1px solid #2c343c; }
.nrst-title { color: #39d3c0; font-size: 13px; font-weight: bold; letter-spacing: 1px; }
.nrst-title { color: #36d2ff; font-size: 13px; font-weight: bold; letter-spacing: 2px; }
.nrst-tabs { display: flex; gap: 3px; margin-left: auto; }
.nrst-tabs button { background: #1c242c; color: #9fb0bd; border: 1px solid #2c343c; border-radius: 2px; font: inherit; font-size: 11px; padding: 2px 9px; cursor: pointer; }
.nrst-tabs button.on { background: #0c9; color: #04201c; border-color: #0c9; font-weight: bold; }
.nrst-tabs button.on { background: #19b8e6; color: #042230; border-color: #19b8e6; font-weight: bold; }
.nrst-x { background: none; border: none; color: #9fb0bd; cursor: pointer; font-size: 14px; padding: 0 2px; }
.nrst-cols, .nrst-row { display: grid; grid-template-columns: 1.3fr 0.8fr 1fr 1.1fr; align-items: baseline; padding: 2px 8px; column-gap: 4px; }
.nrst-cols { color: #6f808d; font-size: 10px; border-bottom: 1px solid #222; padding-bottom: 4px; }
@@ -144,15 +199,113 @@ body {
.nrst-row .c-xtra { color: #39d3c0; text-align: right; }
.nrst-row .c-name { grid-column: 1 / -1; color: #8b9aa6; font-size: 10px; margin-top: -1px; }
.nrst-list { max-height: 62vh; overflow-y: auto; }
/* DME + ALERTS popups (PFD DME / CAUTION softkeys) — left side, G1000 style */
.pfd-pop {
position: absolute; z-index: 4; right: var(--gwin-right, 4%); bottom: var(--gwin-bottom, 6%); left: auto; top: auto;
width: var(--gwin-maxw, 290px); max-height: var(--gwin-maxh, 44%);
background: #05080b; border: 1px solid #7e8a94; border-radius: 0;
color: #fff; font-family: 'Roboto Mono', monospace;
}
.pfd-pop.alerts { top: auto; }
.pop-grid { display: grid; grid-template-columns: auto 1fr; gap: 4px 12px; padding: 8px 12px; font-size: 15px; }
.pop-grid b { color: #6f808d; font-weight: normal; }
.pop-grid span { color: #fff; text-align: right; }
.pop-tabs { display: flex; gap: 3px; padding: 6px 8px; border-top: 1px solid #2c343c; }
.pop-tabs button { flex: 1; background: #1c242c; color: #9fb0bd; border: 1px solid #2c343c; border-radius: 2px; font: inherit; font-size: 11px; padding: 3px 0; cursor: pointer; }
.pop-tabs button.on { background: #0c9; color: #04201c; border-color: #0c9; font-weight: bold; }
/* airway name labels on the MFD map */
.awy-divicon { background: none; border: none; }
.awy-lbl { color: #8fd0f0; font: 10px 'Roboto Mono', monospace; background: rgba(0,0,0,0.45); padding: 0 2px; border-radius: 2px; white-space: nowrap; }
/* altitude alerter: flash the selected-altitude box (approaching / deviation) */
.alt-alert { animation: altflash 1s steps(1, end) infinite; }
@keyframes altflash { 50% { opacity: 0.25; } }
.alerts-list { padding: 6px 0; max-height: 40vh; overflow-y: auto; }
.alert-row { padding: 4px 12px; font-size: 14px; border-bottom: 1px solid #161b20; }
.alert-row.warn { color: #ff5a4d; font-weight: bold; }
.alert-row.cau { color: #ffce46; }
.alert-none { padding: 10px 12px; color: #6f808d; font-size: 13px; }
/* full-area NRST (MFD page) */
.nrst-window.full { position: absolute; inset: 0; width: auto; max-width: none; border: none; border-radius: 0;
background: #0a0d10; box-shadow: none; z-index: 660; display: flex; flex-direction: column; }
.nrst-window.full .nrst-list { max-height: none; flex: 1; }
/* FLIGHT PLAN page (MFD full / PFD window) */
.fpl.full { position: absolute; inset: 0; z-index: 660; background: #0a0d10; display: flex; flex-direction: column; }
.fpl.win { width: 320px; max-height: 44%; background: #05080b; border: 1px solid #7e8a94; border-radius: 0;
display: flex; flex-direction: column; font-size: 13px; }
.fpl-head { display: flex; align-items: center; gap: 8px; padding: 9px 12px; background: #0a0f14; border-bottom: 1px solid #2c343c;
color: #36d2ff; font-family: var(--ui-font); font-weight: 700; font-size: 13px; letter-spacing: 2px; }
.fpl-tot { margin-left: auto; color: #fff; font-size: 12px; font-family: 'Saira Condensed', monospace; }
.fpl-x { background: none; border: none; color: #9fb0bd; cursor: pointer; font-size: 15px; padding: 0 2px; }
.fpl-cols, .fpl-row { display: grid; grid-template-columns: 1.5fr .8fr .8fr .8fr .9fr 30px; align-items: center; gap: 6px; padding: 4px 12px; }
.fpl-cols { color: #6f808d; font-size: 10px; border-bottom: 1px solid #222; letter-spacing: .5px; }
.fpl-cols span:nth-child(n+2) { text-align: right; }
.fpl-rows { flex: 1; overflow-y: auto; }
.fpl-row { font-size: 16px; border-bottom: 1px solid #161b20; cursor: pointer; font-family: 'Saira Condensed', monospace; }
.fpl-row:hover { background: #11161b; }
.fpl-row.act { background: rgba(255,32,255,.12); }
.fpl-row.sel { box-shadow: inset 0 0 0 1px #0ff; }
.r-wpt { color: #0ff; font-weight: 700; } .r-wpt i { color: #0a8; font-style: normal; font-size: 10px; margin-left: 6px; }
.r-wpt b { font-weight: 700; } .r-wpt b.cur { background: #19b8e6; color: #042230; padding: 0 4px; border-radius: 1px; }
.r-dtk, .r-dis, .r-cum { color: #e7edf2; text-align: right; }
.r-alt { color: #6f808d; text-align: right; }
.r-alt { cursor: pointer; }
.r-alt.dsgn { color: #4fa8ff; } /* designated (VNAV) altitude = blue, per manual S.105 */
.r-alt.refr { color: #e7edf2; } /* reference altitude = white (not in the VNAV profile) */
/* CURRENT VNV PROFILE panel (MFD flight-plan page) */
.fpl-vnav { border-top: 1px solid #2c343c; padding: 6px 12px 8px; font-family: 'Roboto Mono', monospace; }
.fpl-vnav-h { color: #36d2ff; font-size: 11px; letter-spacing: 1px; margin-bottom: 5px; }
.fpl-vnav-grid { display: grid; grid-template-columns: auto 1fr auto 1fr; gap: 3px 10px; align-items: baseline; }
.fpl-vnav-grid b { color: #6f808d; font-weight: normal; font-size: 11px; }
.fpl-vnav-grid span { color: #fff; font-size: 14px; }
.fpl-vnav-grid span u { color: #6f808d; font-size: 9px; text-decoration: none; margin-left: 1px; }
.fpl-vnav-grid .vwpt { color: #4fa8ff; }
.fpl-vnav-none { color: #6f808d; font-size: 12px; }
/* ORIG / DEST subtitle (PFD window) */
.fpl-od { color: #36d2ff; text-align: center; font-family: 'Roboto Mono', monospace; font-size: 14px; padding: 3px 0; border-bottom: 1px solid #1c242c; letter-spacing: 1px; }
/* compact window: DTK/DIS only (drop CUM/ALT), no editor — like the real FPL window */
.fpl.win .fpl-cols span:nth-child(4), .fpl.win .fpl-cols span:nth-child(5),
.fpl.win .r-cum, .fpl.win .r-alt, .fpl.win .r-del, .fpl.win .fpl-entry { display: none; }
.fpl.win .fpl-cols, .fpl.win .fpl-row { grid-template-columns: 1.4fr .8fr 1fr; }
.fpl.win .fpl-row { font-size: 15px; }
.fpl-row.act .r-wpt, .fpl-row.act .r-dtk, .fpl-row.act .r-dis, .fpl-row.act .r-cum, .fpl-row.act .r-alt { color: #ff5bff; }
.r-del { background: none; border: none; color: #c44; font-size: 15px; cursor: pointer; }
.fpl-empty { color: #6f808d; text-align: center; padding: 18px; font-size: 13px; font-family: var(--ui-font); }
.fpl-entry { border-top: 1px solid #2c343c; padding: 10px 12px; background: #0c1116; font-family: var(--ui-font); }
.fpl-hits { display: flex; flex-direction: column; gap: 3px; margin-bottom: 8px; }
.fpl-hits button { display: flex; align-items: baseline; gap: 8px; background: #141a20; border: 1px solid #222b33; color: #cfd6dd; font: inherit; padding: 5px 8px; cursor: pointer; text-align: left; }
.fpl-hits b { color: #0ff; } .fpl-hits i { color: #0a8; font-style: normal; font-size: 11px; } .fpl-hits span { color: #6f808d; font-size: 11px; margin-left: auto; }
.fpl-inrow { display: flex; gap: 8px; }
.fpl-inrow input { flex: 1; background: #05080b; border: 1px solid #2c343c; color: #0ff; font: inherit; font-size: 15px; letter-spacing: 1px; padding: 9px 10px; text-transform: uppercase; }
.fpl-actions { display: flex; gap: 8px; margin-top: 8px; }
.fpl-btn { flex: 1; background: #232323; color: #e0e0e0; border: 1px solid #3a3a3a; border-radius: 8px; padding: 9px; font: inherit; font-weight: 700; font-size: 13px; cursor: pointer; }
.fpl-btn.add { background: #f0f0f0; color: #0f0f0f; border-color: transparent; flex: 0 0 auto; padding: 9px 16px; }
.fpl-btn:hover:not(:disabled) { filter: brightness(1.15); } .fpl-btn:disabled { opacity: .4; cursor: default; }
.fpl-msg { margin-top: 6px; font-size: 12px; } .fpl-msg.ok { color: #39d3c0; } .fpl-msg.err { color: #ffae42; }
/* saved-plans load picker */
.fpl-load { position: absolute; inset: 0; z-index: 10; background: #000a; display: flex; align-items: center; justify-content: center; }
.fpl-load-box { width: min(380px, 90%); max-height: 80%; background: #0c1116; border: 1px solid #2c343c; border-radius: 10px; display: flex; flex-direction: column; box-shadow: 0 12px 36px rgba(0,0,0,.6); }
.fpl-load-head { display: flex; align-items: center; justify-content: space-between; padding: 9px 12px; background: #11161b; border-bottom: 1px solid #2c343c; color: #39d3c0; font-family: var(--ui-font); font-weight: 700; font-size: 13px; }
.fpl-load-head button { background: none; border: none; color: #9fb0bd; cursor: pointer; font-size: 14px; }
.fpl-load-list { overflow-y: auto; padding: 6px; display: flex; flex-direction: column; gap: 4px; }
.fpl-load-list button { background: #141a20; border: 1px solid #222b33; color: #0ff; font-family: 'Saira Condensed', monospace; font-size: 16px; font-weight: 700; text-align: left; padding: 9px 12px; border-radius: 6px; cursor: pointer; }
.fpl-load-list button:hover { background: #1c252e; }
.fpl-load-list button i { color: #6f808d; font-style: normal; font-size: 11px; margin-left: 6px; }
/* page-group indicator (bottom-right), like the real G1000; tap = next page */
.mfd-pageind { position: absolute; right: 6px; bottom: 6px; z-index: 700; display: flex; align-items: center; gap: 6px;
background: #000a; border: 1px solid #2c343c; border-radius: 4px; padding: 5px 8px; cursor: pointer;
font: 700 12px/1 monospace; color: #0ff; }
.mfd-pageind em { width: 8px; height: 8px; border: 1px solid #0ff; display: inline-block; }
.mfd-pageind em.on { background: #0ff; }
.nrst-empty { color: #6f808d; text-align: center; padding: 12px; font-size: 12px; }
/* XPDR squawk-entry readout above the softkey keypad */
.squawk-entry { text-align: center; color: #9fb0bd; font-family: 'Roboto Mono', monospace; font-size: 13px; letter-spacing: 1px; padding: 3px 0; }
.squawk-entry b { color: #19ff19; font-size: 18px; letter-spacing: 5px; margin-left: 6px; }
/* TMR/REF window — left side of the PFD */
.tmr-window {
position: absolute; z-index: 4; top: 9%; left: 1.5%; width: 30%; max-width: 320px;
background: rgba(8, 10, 12, 0.94); border: 1px solid #4a5560; border-radius: 3px;
color: #fff; font-family: 'Roboto Mono', monospace; box-shadow: 0 4px 18px rgba(0,0,0,0.6);
position: absolute; z-index: 4; right: var(--gwin-right, 4%); bottom: var(--gwin-bottom, 6%);
width: var(--gwin-maxw, 290px); max-height: var(--gwin-maxh, 44%); overflow-y: auto;
background: #05080b; border: 1px solid #7e8a94; border-radius: 0;
color: #fff; font-family: 'Roboto Mono', monospace;
}
.tmr-body { padding: 8px 10px; }
.tmr-clock { font-size: 34px; font-weight: bold; text-align: center; color: #fff; letter-spacing: 2px; }
@@ -177,24 +330,40 @@ body {
.tmr-minalert { margin-top: 6px; text-align: center; background: #ffd24a; color: #1a1400; font-weight: bold; padding: 3px; border-radius: 2px; letter-spacing: 2px; }
/* Modal dialogs (Direct-To, …) */
.dlg-backdrop { position: fixed; inset: 0; z-index: 20; background: rgba(0,0,0,0.55); display: flex; align-items: center; justify-content: center; }
.dlg { background: #0c1015; border: 1px solid #3a4651; border-radius: 5px; min-width: 320px; color: #fff; font-family: 'Roboto Mono', monospace; box-shadow: 0 8px 30px rgba(0,0,0,0.7); }
.dlg-head { background: #11161b; padding: 8px 12px; border-bottom: 1px solid #2c343c; color: #fff; font-weight: bold; letter-spacing: 1px; border-radius: 5px 5px 0 0; }
/* G1000 side-window dialogs (PROC / Direct-To / FPL): compact panels in the
display's lower-right, no screen dimming like the real unit. */
.gwin-backdrop { position: absolute; inset: 0; z-index: 20; background: transparent; display: flex; align-items: flex-end; justify-content: flex-end; padding: 0 var(--gwin-right, 4%) var(--gwin-bottom, 6%) 0; }
.dlg { background: #05080b; border: 1px solid #7e8a94; border-radius: 0; min-width: 0; color: #fff; font-family: 'Roboto Mono', monospace; }
/* G1000 side-windows fill the lower-right zone (clear of HSI + baro box) */
.gwin-backdrop .dlg, .fpl.win { width: var(--gwin-maxw, 290px); max-width: var(--gwin-maxw, 290px); max-height: var(--gwin-maxh, 44%); }
.dlg-head { background: #0a0f14; padding: 6px 12px; border-bottom: 1px solid #2c343c; color: #36d2ff; font-weight: bold; letter-spacing: 2px; text-align: center; border-radius: 2px 2px 0 0; }
.dto-arrow { color: #e040fb; margin-right: 8px; }
.dto-body { padding: 12px; }
.dto-lbl { color: #6f808d; font-size: 11px; display: block; margin-bottom: 4px; }
.dto-input { width: 100%; box-sizing: border-box; background: #05080b; border: 1px solid #2c343c; color: #0ff; font: inherit; font-size: 20px; letter-spacing: 3px; padding: 6px 10px; text-transform: uppercase; }
.dto-hits { display: flex; flex-direction: column; gap: 3px; margin-top: 6px; }
.dto-hits button { display: flex; align-items: baseline; gap: 8px; background: #141a20; border: 1px solid #222b33; color: #cfd6dd; font: inherit; padding: 5px 8px; cursor: pointer; text-align: left; }
.dto-hits button.on { border-color: #0c9; background: #0a1f1b; }
.dto-hits button b { color: #0ff; } .dto-hits button i { color: #0a8; font-style: normal; font-size: 11px; } .dto-hits button span { color: #6f808d; font-size: 11px; margin-left: auto; }
.dto-sel { display: flex; align-items: baseline; gap: 10px; margin-top: 10px; padding-top: 8px; border-top: 1px solid #222; }
.dto-sel .dto-id { color: #0ff; font-size: 20px; font-weight: bold; }
.dto-sel .dto-type { color: #0a8; font-size: 11px; }
.dto-sel .dto-vec { color: #e040fb; margin-left: auto; font-weight: bold; }
.dto-body { padding: 10px 12px 12px; }
.dto-ident { display: block; width: 100%; box-sizing: border-box; background: none; border: none; border-bottom: 1px solid #2c343c;
color: #36d2ff; font: inherit; font-size: 24px; font-weight: bold; letter-spacing: 3px; padding: 2px 2px 4px; text-transform: uppercase; outline: none; }
.dto-ident::placeholder { color: #2c4a57; }
.dto-name { color: #cdd6dd; font-size: 13px; min-height: 17px; padding: 3px 2px 0; }
.dto-hits { display: flex; flex-direction: column; gap: 2px; margin-top: 5px; }
.dto-hits button { display: flex; align-items: baseline; gap: 10px; background: #0c1116; border: 1px solid #1c242c; color: #cfd6dd; font: inherit; padding: 4px 8px; cursor: pointer; text-align: left; }
.dto-hits button:hover { background: #13202a; border-color: #36d2ff; }
.dto-hits button b { color: #36d2ff; } .dto-hits button span { color: #6f808d; font-size: 11px; margin-left: auto; }
.dto-grid { display: grid; grid-template-columns: auto 1fr auto 1fr; align-items: baseline; gap: 7px 8px; margin-top: 10px; padding-top: 9px; border-top: 1px solid #222; }
.dto-grid b { color: #6f808d; font-weight: normal; font-size: 12px; }
.dto-grid span { color: #fff; font-size: 15px; }
.dto-foot { display: flex; justify-content: flex-end; margin-top: 12px; }
.dto-act { background: #0c1116; border: 1px solid #7e8a94; color: #36d2ff; font: inherit; font-weight: bold; letter-spacing: 1px; font-size: 14px; padding: 5px 14px; cursor: pointer; }
.dto-act:hover:not(:disabled) { background: #19b8e6; color: #042230; border-color: #19b8e6; }
.dto-act:disabled { opacity: .4; cursor: default; }
.dlg-actions { display: flex; gap: 8px; padding: 10px 12px; border-top: 1px solid #2c343c; }
.dlg-actions .fbtn { flex: 1; }
/* PROC dialog */
.dlg.proc { width: 640px; max-width: 92vw; }
.dlg.proc, .dlg.proc.menu { width: var(--gwin-maxw, 290px); max-width: var(--gwin-maxw, 290px); display: flex; flex-direction: column; }
.dlg.proc .proc-body { flex: 1; min-height: 0; display: flex; flex-direction: column; }
.proc-menu { display: flex; flex-direction: column; padding: 4px 0; }
.proc-menu-i { background: none; border: none; border-bottom: 1px solid #11161b; color: #d7e2ea; font: inherit; font-family: 'Roboto Mono', monospace; font-size: 13px; text-align: left; padding: 6px 12px; cursor: pointer; letter-spacing: .5px; }
.proc-menu-i:hover { background: #11161b; }
.proc-menu-i.sel { background: #19b8e6; color: #042230; font-weight: bold; }
.proc-back { background: #1c242c; border: 1px solid #2c343c; color: #36d2ff; font: inherit; font-size: 14px; line-height: 1; padding: 4px 9px; cursor: pointer; border-radius: 2px; }
.proc-body { padding: 12px; }
.proc-apt { display: flex; align-items: center; gap: 8px; }
.proc-apt label { color: #6f808d; font-size: 11px; }
@@ -203,16 +372,19 @@ body {
.proc-err { color: #ffae42; font-size: 12px; margin-top: 6px; }
.proc-tabs { display: flex; gap: 4px; margin: 10px 0 6px; }
.proc-tabs button { flex: 1; background: #1c242c; color: #9fb0bd; border: 1px solid #2c343c; font: inherit; font-size: 12px; padding: 5px; cursor: pointer; }
.proc-tabs button.on { background: #0c9; color: #04201c; font-weight: bold; border-color: #0c9; }
.proc-cols { display: grid; grid-template-columns: 1fr 1fr 1.4fr; gap: 6px; height: 300px; }
.proc-tabs button.on { background: #19b8e6; color: #042230; font-weight: bold; border-color: #19b8e6; }
.proc-cols { display: grid; grid-template-columns: 1fr 1fr 1.3fr; gap: 5px; flex: 1; min-height: 0; }
.proc-list, .proc-preview { background: #05080b; border: 1px solid #1c242c; overflow-y: auto; display: flex; flex-direction: column; }
.proc-coltitle { position: sticky; top: 0; background: #11161b; color: #6f808d; font-size: 10px; padding: 4px 8px; border-bottom: 1px solid #222; }
.proc-list button { background: none; border: none; border-bottom: 1px solid #11161b; color: #cfd6dd; font: inherit; font-size: 14px; text-align: left; padding: 6px 8px; cursor: pointer; }
.proc-list button:hover { background: #11161b; }
.proc-list button.on { background: #0a1f1b; color: #0ff; font-weight: bold; }
.proc-list button.on { background: #19b8e6; color: #042230; font-weight: bold; }
.proc-empty { color: #6f808d; font-size: 11px; padding: 8px; }
.proc-leg { display: flex; align-items: baseline; gap: 8px; padding: 5px 8px; border-bottom: 1px solid #11161b; font-size: 13px; }
.proc-leg b { color: #0ff; } .proc-leg u { color: #39d3c0; font-size: 10px; text-decoration: none; margin-left: auto; }
.proc-leg.missed b { color: #8aa0ad; } /* missed-approach legs shown dimmed */
.proc-leg .missed-tag { color: #6f808d; font-style: normal; font-size: 9px; border: 1px solid #2a3640; border-radius: 2px; padding: 0 3px; margin-left: 6px; }
.proc-note { color: #ffce46; font-size: 11px; padding: 8px 12px; border-top: 1px solid #11161b; }
/* G1000 vector nav symbology drawn from X-Plane's own nav data */
.nav-divicon { background: none; border: none; }
.nav-sym { position: relative; width: 18px; height: 18px; }
@@ -225,7 +397,7 @@ body {
/* ---- GDU-1040 bezel ---- */
.bezel {
width: 100%; height: 100%; display: flex; align-items: stretch; gap: 0;
width: 100%; height: 100%; display: flex; align-items: stretch; gap: 12px;
background: linear-gradient(150deg, #3a3c40, #202123 55%, #2c2d30);
border-radius: 18px; padding: 12px; box-shadow: inset 0 1px 0 #4a4c50, 0 8px 30px #000;
font-family: 'Saira Semi Condensed', sans-serif;
@@ -233,16 +405,24 @@ body {
.bezel-core { flex: 1; display: flex; flex-direction: column; min-width: 0; }
.bezel-title { text-align: center; color: #c9ced3; font-size: 14px; font-weight: 700; letter-spacing: 3px; padding: 2px 0 6px; }
.bezel-screen {
flex: 1; background: #000; border-radius: 6px; overflow: hidden; position: relative;
border: 2px solid #0a0a0a; box-shadow: inset 0 0 18px #000, inset 0 0 2px #1a3a5a;
display: flex; min-height: 0;
flex: 1; background: #000; overflow: hidden; position: relative;
display: flex; flex-direction: column; min-height: 0;
}
.bezel-screen > * { width: 100%; height: 100%; }
.softkeys { display: grid; grid-template-columns: repeat(12, 1fr); gap: 6px; padding: 8px 2px 2px; }
.screen-content { flex: 1; min-height: 0; width: 100%; position: relative; }
.screen-content > * { width: 100%; height: 100%; }
/* on-screen softkey label row (lowest line of the display) */
.sk-labels { flex: 0 0 auto; display: grid; grid-template-columns: repeat(12, 1fr); gap: 4px;
padding: 3px 2px; background: #000; border-top: 1px solid #1c1c1c; }
.skl { display: flex; align-items: center; justify-content: center; height: 20px; border-radius: 3px;
color: #e8edf2; font-size: 11px; font-weight: 700; letter-spacing: .3px; font-family: 'Saira Semi Condensed', sans-serif; }
.skl.empty { color: transparent; }
.skl.on { background: #e8edf2; color: #0a0c0e; }
.skl.caution { background: linear-gradient(#ffd23a, #e0b400); color: #1a1b1e; }
.softkeys { display: grid; grid-template-columns: repeat(12, 1fr); gap: 4px; padding: 4px 2px 1px; }
.softkey {
height: 30px; display: flex; align-items: center; justify-content: center;
height: 20px; display: flex; align-items: center; justify-content: center;
background: linear-gradient(#202224, #131416); border: 1px solid #000; border-top: 1px solid #45474b;
border-radius: 4px; color: #cfd6dc; font-size: 12px; font-weight: 600; letter-spacing: .3px;
border-radius: 3px; color: #cfd6dc; font-size: 10.5px; font-weight: 600; letter-spacing: .2px;
box-shadow: 0 1px 2px #000; cursor: pointer; font-family: inherit;
}
.softkey:not(.empty):hover { background: linear-gradient(#2a2c2f, #1a1b1e); border-top-color: #5a5d61; }
@@ -251,19 +431,33 @@ body {
.softkey.on { background: #e8edf2; color: #0a0c0e; border-top-color: #fff; font-weight: 800; }
.softkey.caution { color: #1a1b1e; background: linear-gradient(#ffd23a, #e0b400); border-top-color: #fff2a8; font-weight: 800; }
.bezel-knobs { display: flex; flex-direction: column; align-items: center; justify-content: space-around; padding: 4px 6px; gap: 6px; }
.bezel-knobs.left { width: 88px; } .bezel-knobs.right { width: 100px; }
.bezel-knobs { display: flex; flex-direction: column; align-items: center; padding: 12px 6px; gap: 14px; flex: 0 0 104px; width: 104px; }
/* NAV/HDG (+ AP block on MFD) group at the top, ALT pinned to the bottom */
.bezel-knobs.left { justify-content: flex-start; }
.bezel-knobs.left > .knob-wrap:last-child { margin-top: auto; }
/* COM at top … FMS at bottom, evenly spread */
.bezel-knobs.right { justify-content: space-between; }
.knob-wrap { display: flex; flex-direction: column; align-items: center; gap: 2px; position: relative; }
.knob-lbl { color: #d2d7dc; font-size: 12px; font-weight: 800; letter-spacing: 1px; }
.knob-sub { color: #8b9197; font-size: 8.5px; font-weight: 600; letter-spacing: .3px; text-align: center; }
.knob-extra { position: absolute; right: -10px; top: 6px; width: 20px; height: 16px; background: #1a1b1e; border: 1px solid #000; border-radius: 3px; color: #cfd6dc; font-size: 11px; text-align: center; line-height: 16px; }
.knob-swap { position: absolute; right: 2px; top: 0; width: 26px; height: 20px; background: linear-gradient(#2a2c2f, #16171a); border: 1px solid #000; border-top: 1px solid #45474b; border-radius: 4px; color: #0ff; font-size: 13px; cursor: pointer; padding: 0; line-height: 1; }
.knob-swap:active { background: #000; }
.knob-emerg { position: absolute; left: 2px; top: 2px; color: #c33; font-size: 8px; font-weight: 700; letter-spacing: .3px; }
.knob.outer {
width: 50px; height: 50px; border-radius: 50%; display: flex; align-items: center; justify-content: center; position: relative;
width: 60px; height: 60px; border-radius: 50%; display: flex; align-items: center; justify-content: center; position: relative;
background: radial-gradient(circle at 35% 30%, #55585d, #2a2c2f 70%); box-shadow: 0 2px 5px #000, inset 0 1px 0 #6a6d72;
}
.knob-wrap.big .knob.outer { width: 58px; height: 58px; }
.knob-wrap.big .knob.outer { width: 68px; height: 68px; }
.knob.inner { width: 26px; height: 26px; border-radius: 50%; background: radial-gradient(circle at 35% 30%, #44474b, #1c1e20); box-shadow: inset 0 1px 0 #5a5d61; }
.knob.joy .joy-cross { position: absolute; color: #6a6d72; font-size: 22px; font-weight: 700; pointer-events: none; }
/* RANGE knob: white surround ring + zoom /+ and curved arrows (like the GDU) */
.knob.joy { overflow: visible; }
.rng-ring { position: absolute; inset: -10px; border-radius: 50%; border: 2px solid #d2d7dc; pointer-events: none; }
.rng-sign { position: absolute; top: 50%; transform: translateY(-50%); color: #d2d7dc; font-size: 17px; font-weight: 700; pointer-events: none; line-height: 1; }
.rng-sign.m { left: -23px; } .rng-sign.p { right: -23px; }
.rng-arc { position: absolute; top: -14px; color: #d2d7dc; font-size: 15px; pointer-events: none; }
.rng-arc.l { left: -10px; } .rng-arc.r { right: -10px; }
.knob.outer { cursor: pointer; border: none; padding: 0; }
.knob.outer:active { box-shadow: 0 1px 2px #000, inset 0 2px 4px #000; }
@@ -278,6 +472,47 @@ body {
.knob-arrow:active { background: #000; }
.knob-arrow.left { left: -2px; } .knob-arrow.right { right: -2px; }
.knob-arrow.top { top: -2px; } .knob-arrow.bottom { bottom: -2px; }
.knob-cluster.zones { padding: 5px; }
/* settings panel */
.set-lbl { color: var(--c-mut); font-size: 12px; font-weight: 700; letter-spacing: .5px; margin-bottom: 8px; font-family: var(--ui-font); }
.set-opt { display: flex; gap: 8px; }
.set-opt .fbtn { flex: 1; }
.set-hint { color: var(--c-mut); font-size: 11px; margin-top: 10px; line-height: 1.45; font-family: var(--ui-font); }
/* radio tuner — KAP-140 green LCD + macOS-dark launcher chrome */
.rtuner { width: min(400px, 94vw); background: linear-gradient(#23262c, #15171b); border: 1px solid #0a0a0a;
border-top: 1px solid #4a4d52; border-radius: 16px; padding: 16px; font-family: var(--ui-font);
box-shadow: 0 18px 50px rgba(0,0,0,.6), inset 0 1px 0 rgba(255,255,255,.05); }
.rt-head { display: flex; align-items: center; gap: 10px; margin-bottom: 14px; }
.rt-title { color: var(--c-txt); font-size: 18px; font-weight: 700; letter-spacing: .5px; }
.rt-kind { color: #cfd6dd; background: #0f0f0f; border: 1px solid #3a3a3a; font-size: 10px; font-weight: 800; letter-spacing: 1px; padding: 2px 8px; border-radius: 999px; }
/* green LCD with both frequencies */
.rt-lcd { display: flex; align-items: center; justify-content: space-between; gap: 10px;
background: #06160b; border: 1px solid #0a4d24; border-radius: 10px; padding: 12px 14px; margin-bottom: 16px;
box-shadow: inset 0 0 22px rgba(0,90,35,.5); }
.rt-f { display: flex; flex-direction: column; gap: 3px; }
.rt-f.right { align-items: flex-end; }
.rt-f span { color: #1f9d52; font-size: 10px; letter-spacing: 1.5px; }
.rt-f b { font-family: 'Saira Condensed', monospace; font-size: 30px; font-weight: 700; line-height: 1; }
.rt-f b.act { color: #3bff6e; text-shadow: 0 0 12px rgba(59,255,110,.55); }
.rt-f b.sby { color: #f0f0f0; text-shadow: 0 0 8px rgba(240,240,240,.3); }
.rt-swap { flex: 0 0 auto; width: 48px; height: 40px; background: linear-gradient(#2b2b2b, #161616); color: #e0e0e0;
border: 1px solid #08090b; border-top: 1px solid #4a4a4a; border-radius: 9px; font-size: 22px; cursor: pointer;
box-shadow: 0 2px 4px rgba(0,0,0,.5), inset 0 1px 0 rgba(255,255,255,.08); }
.rt-swap:hover { color: #fff; } .rt-swap:active { transform: translateY(1px); background: #3a3a3a; color: #fff; }
.rt-tune { display: flex; flex-direction: column; gap: 10px; }
.rt-row { display: grid; grid-template-columns: 64px 1fr 1fr; gap: 10px; align-items: center; }
.rt-unit { color: var(--c-txt2); font-size: 13px; font-weight: 700; letter-spacing: .5px; text-align: center;
background: #2c2f35; border: 1px solid #3a3f47; border-radius: 8px; padding: 8px 0; }
.rt-step { background: linear-gradient(#3b3e44, #23262b); color: #eef2f6; border: 1px solid #08090b; border-top: 1px solid #5c6168;
border-radius: 10px; padding: 16px 0; font-size: 24px; font-weight: 700; cursor: pointer;
box-shadow: 0 2px 5px rgba(0,0,0,.55), inset 0 1px 0 rgba(255,255,255,.1); }
.rt-step:hover { background: linear-gradient(#454545, #2a2a2a); }
.rt-step:active { transform: translateY(1px); background: linear-gradient(#3a3a3a, #1f1f1f); color: #fff; box-shadow: inset 0 2px 5px rgba(0,0,0,.6); }
.rt-actions { display: flex; gap: 10px; margin-top: 16px; }
.rt-btn { flex: 1; background: #232323; color: var(--c-txt2); border: 1px solid #3a3a3a; border-radius: 10px; padding: 13px; font-family: var(--ui-font); font-size: 14px; font-weight: 700; cursor: pointer; }
.rt-btn:hover { background: #2e2e2e; }
.rt-btn.primary { background: #f0f0f0; color: #0f0f0f; border-color: transparent; }
.rt-btn.primary:active { transform: translateY(1px); filter: brightness(.92); }
.pan-pad { display: grid; grid-template-columns: repeat(2, 14px); gap: 2px; margin-top: 3px; }
.pan-pad button {
@@ -325,6 +560,10 @@ body {
font: 600 12px/1 monospace; color: #0ff; background: #000a; padding: 4px 6px; }
.mc-mode em { width: 8px; height: 8px; border: 1px solid #0ff; display: inline-block; }
.mc-mode em.on { background: #0ff; }
.mc-wind { position: absolute; left: 6px; top: 6px; display: flex; gap: 6px; align-items: center;
font: 600 12px/1 monospace; color: #fff; background: #000a; padding: 4px 7px; }
.mc-wind i { color: #9ab; font-style: normal; }
.mc-windarr { display: inline-block; font-size: 16px; line-height: 1; color: #cfe3ff; }
/* Autopilot — GMC-710-style AFCS mode controller (app chrome look) */
.afcs { width: 100%; height: 100%; display: flex; align-items: center; justify-content: center;
@@ -482,10 +721,28 @@ body {
background: #08240f; color: #29f06a; border: 1px solid #0a5; border-radius: 8px;
padding: 12px 16px; font-weight: 800; font-family: monospace; letter-spacing: 1px;
}
.fbtn.add { background: #0a5; color: #021008; }
.fbtn.add { background: #f0f0f0; color: #0f0f0f; }
.fbtn.export { background: #ffae42; color: #2a1500; border-color: #ffae42; flex: 1; }
.fbtn:disabled { opacity: .4; }
.fms-actions { display: flex; gap: 8px; margin-top: 8px; }
.fms-export { margin-top: 8px; font-size: 13px; padding: 8px; border-radius: 6px; }
.fms-export.ok { background: #06330f; color: #9f9; }
.fms-export.err { background: #330606; color: #f99; }
/* ---------------- Audio Panel (X1000) ---------------- */
.audio-panel { width: 100%; height: 100%; display: flex; align-items: center; justify-content: center; background: var(--c-bg, #0f0f0f); }
.apnl { width: min(420px, 94%); background: #15191e; border: 1px solid #2c343c; border-radius: 10px; padding: 14px 16px 18px; box-shadow: 0 8px 30px rgba(0,0,0,.5); font-family: var(--ui-font, 'Inter', system-ui); }
.apnl-title { text-align: center; color: #36d2ff; font-weight: 700; letter-spacing: 2px; font-size: 14px; margin-bottom: 12px; }
.apnl-grp { margin-bottom: 12px; }
.apnl-h { color: #6f808d; font-size: 10px; letter-spacing: 1px; margin-bottom: 5px; }
.apnl-row { display: grid; grid-template-columns: 1fr 1fr; gap: 8px; margin-bottom: 8px; }
.apk { display: flex; flex-direction: column; align-items: center; gap: 2px; background: #1c2229; border: 1px solid #313a44; border-radius: 6px; color: #c9d3db; font: inherit; font-size: 12px; font-weight: 600; padding: 9px 6px; cursor: pointer; box-shadow: inset 0 1px 0 rgba(255,255,255,.04); }
.apk:hover { background: #232a32; }
.apk .apk-s { font-size: 9px; color: #6f808d; font-weight: 400; }
.apk.on { border-color: #19b8e6; background: #0d2c38; color: #7fe0ff; box-shadow: 0 0 0 1px #19b8e6, 0 0 10px rgba(25,184,230,.25); }
.apk.mic.on { border-color: #16c116; background: #0c2a0c; color: #7bf07b; box-shadow: 0 0 0 1px #16c116, 0 0 10px rgba(22,193,22,.25); }
.apnl-vol { display: flex; align-items: center; gap: 8px; margin-top: 6px; color: #9fb0bd; font-size: 11px; }
.apnl-vol input { flex: 1; accent-color: #19b8e6; }
.apnl-vol b { color: #fff; font-size: 12px; min-width: 26px; text-align: right; }
.apnl-backup { width: 100%; margin-top: 6px; background: #3a0d0d; border: 1px solid #b53333; border-radius: 8px; color: #ff8a8a; font: inherit; font-weight: 700; letter-spacing: 1px; font-size: 12px; padding: 11px; cursor: pointer; }
.apnl-backup:hover { background: #5a1414; color: #ffb0b0; }