Compare commits
19 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 55ea7fdcc8 | |||
| b9241e60c8 | |||
| e8890478dd | |||
| 10d4a4facf | |||
| 1734a2d7ac | |||
| ad592a7a77 | |||
| 95995211a0 | |||
| 0ceb1dede3 | |||
| e8dfa84266 | |||
| 6756acab4a | |||
| b05ffedbc1 | |||
| aa64959eea | |||
| 28ab984185 | |||
| 474a35c6e3 | |||
| a32b5a9b06 | |||
| 5f63c5032c | |||
| 3d6d3f710e | |||
| 5f1339f8b3 | |||
| 5db22c85bc |
Generated
+1
-1
@@ -5900,7 +5900,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "xplane-cockpit"
|
||||
version = "0.1.5"
|
||||
version = "0.1.6"
|
||||
dependencies = [
|
||||
"local-ip-address",
|
||||
"serde",
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
[package]
|
||||
name = "xplane-cockpit"
|
||||
version = "0.1.5"
|
||||
version = "0.1.6"
|
||||
description = "Desktop launcher for the X-Plane G1000 web cockpit"
|
||||
authors = ["karim"]
|
||||
edition = "2021"
|
||||
|
||||
@@ -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)
|
||||
@@ -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)
|
||||
@@ -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)")
|
||||
@@ -66,30 +66,71 @@ fn suggest_port(start: u16) -> u16 {
|
||||
start
|
||||
}
|
||||
|
||||
// A directory is an X-Plane 12 root iff it holds Resources/default data.
|
||||
fn is_xplane_root(p: &std::path::Path) -> bool {
|
||||
p.join("Resources").join("default data").is_dir()
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
fn default_xplane_path() -> Option<String> {
|
||||
let home = std::env::var("HOME")
|
||||
.or_else(|_| std::env::var("USERPROFILE"))
|
||||
.unwrap_or_default();
|
||||
let candidates = [
|
||||
let user = std::env::var("USER")
|
||||
.or_else(|_| std::env::var("USERNAME"))
|
||||
.unwrap_or_default();
|
||||
|
||||
let mut candidates = vec![
|
||||
// Generic / cross-platform
|
||||
format!("{home}/X-Plane 12"),
|
||||
format!("{home}/Desktop/X-Plane 12"),
|
||||
format!("{home}/Games/X-Plane 12"),
|
||||
// Steam on Linux — native, plus the Flatpak Steam sandbox path
|
||||
format!("{home}/.steam/steam/steamapps/common/X-Plane 12"),
|
||||
format!("{home}/.local/share/Steam/steamapps/common/X-Plane 12"),
|
||||
format!("{home}/.var/app/com.valvesoftware.Steam/.local/share/Steam/steamapps/common/X-Plane 12"),
|
||||
// System-wide (Linux)
|
||||
"/opt/X-Plane 12".to_string(),
|
||||
// macOS
|
||||
"/Applications/X-Plane 12".to_string(),
|
||||
// Windows
|
||||
"C:/X-Plane 12".to_string(),
|
||||
"D:/X-Plane 12".to_string(),
|
||||
];
|
||||
|
||||
// Secondary/external drives: Steam libraries on extra disks usually mount
|
||||
// under /media/$USER, /run/media/$USER or /mnt. Probe each mounted volume
|
||||
// for the standard SteamLibrary layout (and a bare "X-Plane 12" folder).
|
||||
for base in [
|
||||
format!("/media/{user}"),
|
||||
format!("/run/media/{user}"),
|
||||
"/mnt".to_string(),
|
||||
] {
|
||||
if let Ok(entries) = std::fs::read_dir(&base) {
|
||||
for vol in entries.flatten().map(|e| e.path()) {
|
||||
candidates.push(
|
||||
vol.join("SteamLibrary/steamapps/common/X-Plane 12")
|
||||
.to_string_lossy()
|
||||
.into_owned(),
|
||||
);
|
||||
candidates.push(
|
||||
vol.join("steamapps/common/X-Plane 12")
|
||||
.to_string_lossy()
|
||||
.into_owned(),
|
||||
);
|
||||
candidates.push(vol.join("X-Plane 12").to_string_lossy().into_owned());
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
candidates
|
||||
.into_iter()
|
||||
.find(|c| PathBuf::from(c).join("Resources").join("default data").is_dir())
|
||||
.find(|c| is_xplane_root(&PathBuf::from(c)))
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
fn valid_xplane_path(path: String) -> bool {
|
||||
!path.is_empty()
|
||||
&& PathBuf::from(&path)
|
||||
.join("Resources")
|
||||
.join("default data")
|
||||
.is_dir()
|
||||
!path.is_empty() && is_xplane_root(&PathBuf::from(&path))
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
@@ -97,6 +138,19 @@ fn server_running(state: State<ServerState>) -> bool {
|
||||
state.child.lock().unwrap().is_some()
|
||||
}
|
||||
|
||||
// Is FlyWithLua NG+ installed in this X-Plane? It's the prerequisite for the
|
||||
// FMS/terrain sync — the bridge auto-installs OUR scripts into its Scripts
|
||||
// folder, but only if the plugin itself is present. Checked by the setup wizard.
|
||||
#[tauri::command]
|
||||
fn flywithlua_present(path: String) -> bool {
|
||||
!path.is_empty()
|
||||
&& PathBuf::from(&path)
|
||||
.join("Resources")
|
||||
.join("plugins")
|
||||
.join("FlyWithLua")
|
||||
.is_dir()
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
async fn start_server(
|
||||
app: tauri::AppHandle,
|
||||
@@ -203,6 +257,7 @@ pub fn run() {
|
||||
suggest_port,
|
||||
default_xplane_path,
|
||||
valid_xplane_path,
|
||||
flywithlua_present,
|
||||
server_running,
|
||||
start_server,
|
||||
stop_server
|
||||
@@ -211,11 +266,17 @@ pub fn run() {
|
||||
build_tray(app.handle())?;
|
||||
Ok(())
|
||||
})
|
||||
// Closing the window hides it instead of quitting, so the server keeps
|
||||
// serving tablets in the background. Quit from the tray.
|
||||
// Closing the window keeps the app alive so the server keeps serving
|
||||
// tablets in the background. On macOS/Windows the tray icon is always
|
||||
// reachable, so fully hide. On Linux many desktops (notably vanilla
|
||||
// GNOME) show no tray at all, so hiding would strand the app with no way
|
||||
// back — minimize instead, keeping it in the taskbar while it runs.
|
||||
.on_window_event(|window, event| {
|
||||
if let tauri::WindowEvent::CloseRequested { api, .. } = event {
|
||||
api.prevent_close();
|
||||
#[cfg(target_os = "linux")]
|
||||
let _ = window.minimize();
|
||||
#[cfg(not(target_os = "linux"))]
|
||||
let _ = window.hide();
|
||||
}
|
||||
})
|
||||
|
||||
@@ -2,5 +2,14 @@
|
||||
#![cfg_attr(not(debug_assertions), windows_subsystem = "windows")]
|
||||
|
||||
fn main() {
|
||||
// WebKitGTK's DMABUF renderer shows a black/blank window on a number of Linux
|
||||
// GPU/driver combinations (notably Nvidia and older Mesa). This launcher is a
|
||||
// simple control panel, so favour reliability over GPU compositing: disable
|
||||
// the DMABUF renderer unless the user has already set the variable themselves.
|
||||
#[cfg(target_os = "linux")]
|
||||
if std::env::var_os("WEBKIT_DISABLE_DMABUF_RENDERER").is_none() {
|
||||
std::env::set_var("WEBKIT_DISABLE_DMABUF_RENDERER", "1");
|
||||
}
|
||||
|
||||
xplane_cockpit_lib::run()
|
||||
}
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"$schema": "https://schema.tauri.app/config/2",
|
||||
"productName": "X-Plane Cockpit",
|
||||
"version": "0.1.5",
|
||||
"version": "0.1.6",
|
||||
"identifier": "ch.kgva.xplanecockpit",
|
||||
"build": {
|
||||
"frontendDist": "../ui"
|
||||
|
||||
@@ -10,6 +10,7 @@
|
||||
<div class="panel">
|
||||
<header class="hd">
|
||||
<div class="brand">G1000<span>·web</span></div>
|
||||
<button id="setupBtn" class="link" title="Einrichtungs-Assistent">⚙ Einrichten</button>
|
||||
<div id="status" class="status off"><span class="dot"></span><span id="statusText">Gestoppt</span></div>
|
||||
</header>
|
||||
|
||||
@@ -64,6 +65,7 @@
|
||||
<div class="diag-row"><span>Verbundene Geräte</span><b id="dClients">—</b></div>
|
||||
<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 class="diag-row"><span>FlyWithLua-Sync</span><b id="dLua">—</b></div>
|
||||
</div>
|
||||
|
||||
<details class="asp-wrap">
|
||||
@@ -88,6 +90,66 @@
|
||||
<button id="updateBtn" class="link">Nach Updates suchen</button>
|
||||
</footer>
|
||||
</div>
|
||||
|
||||
<!-- First-run setup wizard: links X-Plane, checks FlyWithLua, installs the Lua
|
||||
scripts, explains the Web-API, then starts. -->
|
||||
<div id="wizard" class="wiz hidden">
|
||||
<div class="wiz-box">
|
||||
<div class="wiz-head">
|
||||
<b>Einrichtung</b>
|
||||
<div class="wiz-steps">
|
||||
<span data-s="1" class="on">1</span><span data-s="2">2</span><span data-s="3">3</span><span data-s="4">4</span>
|
||||
</div>
|
||||
<button id="wizClose" class="wiz-x" title="Schließen">✕</button>
|
||||
</div>
|
||||
|
||||
<!-- Step 1: X-Plane folder -->
|
||||
<div class="wiz-step" data-step="1">
|
||||
<h3>1 · X-Plane 12 Ordner</h3>
|
||||
<p>Wähle deinen X-Plane-12-Ordner. (Demo ohne X-Plane: einfach überspringen.)</p>
|
||||
<div class="row">
|
||||
<input id="wizPath" type="text" placeholder="z.B. /Users/du/X-Plane 12" spellcheck="false" />
|
||||
<button id="wizBrowse" class="btn ghost">Suchen…</button>
|
||||
</div>
|
||||
<div id="wizPathHint" class="hint"></div>
|
||||
</div>
|
||||
|
||||
<!-- Step 2: FlyWithLua -->
|
||||
<div class="wiz-step hidden" data-step="2">
|
||||
<h3>2 · FlyWithLua NG+</h3>
|
||||
<p>Für die zweiseitige FMS- und Terrain-Synchronisierung braucht X-Plane das kostenlose Plugin <b>FlyWithLua NG+</b>.</p>
|
||||
<div id="wizFwl" class="wiz-status">—</div>
|
||||
<p class="wiz-sub">Nicht installiert? <a href="#" id="wizFwlLink">FlyWithLua NG+ herunterladen</a>, in <code>X-Plane/Resources/plugins/</code> entpacken, X-Plane neu starten.</p>
|
||||
<button id="wizFwlCheck" class="btn ghost sm">Erneut prüfen</button>
|
||||
</div>
|
||||
|
||||
<!-- Step 3: Web API -->
|
||||
<div class="wiz-step hidden" data-step="3">
|
||||
<h3>3 · X-Plane Web-API aktivieren</h3>
|
||||
<p>Damit das Cockpit Daten bekommt, muss X-Planes Web-Server an sein:</p>
|
||||
<ol class="wiz-ol">
|
||||
<li>X-Plane → <b>Settings</b> → <b>Network</b></li>
|
||||
<li>Bereich <b>„Web/REST API"</b> (X-Plane 12.1.1+)</li>
|
||||
<li><b>„Enable web server"</b> / API einschalten</li>
|
||||
</ol>
|
||||
<p class="wiz-sub">Ob's klappt, siehst du nach dem Start am Status „X-Plane: verbunden".</p>
|
||||
</div>
|
||||
|
||||
<!-- Step 4: install + start -->
|
||||
<div class="wiz-step hidden" data-step="4">
|
||||
<h3>4 · Lua installieren & starten</h3>
|
||||
<p>Beim Start kopiert der Server die Begleit-Skripte automatisch nach <code>FlyWithLua/Scripts/</code> und verbindet sich mit X-Plane.</p>
|
||||
<label class="toggle"><input id="wizDemo" type="checkbox" /><span>Demo-Modus (ohne X-Plane)</span></label>
|
||||
<div id="wizResult" class="wiz-status">Bereit.</div>
|
||||
</div>
|
||||
|
||||
<div class="wiz-foot">
|
||||
<button id="wizBack" class="btn ghost" disabled>Zurück</button>
|
||||
<button id="wizNext" class="btn primary">Weiter</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<script src="main.js"></script>
|
||||
</body>
|
||||
</html>
|
||||
|
||||
+74
-1
@@ -107,9 +107,19 @@ function resetUi() {
|
||||
if (healthTimer) { clearInterval(healthTimer); healthTimer = null; }
|
||||
}
|
||||
|
||||
// Human-readable FlyWithLua-install status from /api/health.lua.
|
||||
function luaText(lua) {
|
||||
if (!lua) return { t: '—', cls: '' };
|
||||
if (lua.reason === 'no-xplane') return { t: 'kein X-Plane', cls: 'warn' };
|
||||
if (lua.reason === 'no-flywithlua') return { t: 'FlyWithLua fehlt', cls: 'bad' };
|
||||
if (lua.reason === 'no-source') return { t: 'Skripte fehlen', cls: 'bad' };
|
||||
const n = (lua.installed?.length || 0) + (lua.updated?.length || 0) + (lua.unchanged?.length || 0);
|
||||
return { t: `${n} Skripte aktiv`, cls: 'ok' };
|
||||
}
|
||||
|
||||
function pollHealth(port) {
|
||||
if (healthTimer) clearInterval(healthTimer);
|
||||
const dXp = $('dXp'), dClients = $('dClients'), dNav = $('dNav'), dRefs = $('dRefs');
|
||||
const dXp = $('dXp'), dClients = $('dClients'), dNav = $('dNav'), dRefs = $('dRefs'), dLua = $('dLua');
|
||||
const check = async () => {
|
||||
try {
|
||||
const r = await fetch(`http://127.0.0.1:${port}/api/health`, { cache: 'no-store' });
|
||||
@@ -123,6 +133,7 @@ function pollHealth(port) {
|
||||
const n = d.nav || {};
|
||||
dNav.textContent = n.loaded ? `${n.airports ?? 0} APT · ${n.navaids ?? 0} Navaids` : 'lädt…';
|
||||
dRefs.textContent = d.datarefs ?? 0;
|
||||
if (dLua) { const l = luaText(d.lua); dLua.textContent = l.t; dLua.className = l.cls; }
|
||||
} catch { setStatus('warn', 'Server läuft'); }
|
||||
};
|
||||
check();
|
||||
@@ -242,4 +253,66 @@ $('updateBtn').addEventListener('click', () => checkUpdate(false));
|
||||
$('ubInstall').addEventListener('click', installUpdate);
|
||||
$('ubDismiss').addEventListener('click', () => $('updateBanner').classList.add('hidden'));
|
||||
|
||||
/* ---------------- first-run setup wizard ---------------- */
|
||||
const FWL_URL = 'https://github.com/X-Friese/FlyWithLua/releases';
|
||||
let wizStep = 1;
|
||||
const wiz = $('wizard');
|
||||
|
||||
function showStep(n) {
|
||||
wizStep = Math.max(1, Math.min(4, n));
|
||||
document.querySelectorAll('.wiz-step').forEach((s) => s.classList.toggle('hidden', +s.dataset.step !== wizStep));
|
||||
document.querySelectorAll('.wiz-steps span').forEach((s) => s.classList.toggle('on', +s.dataset.s <= wizStep));
|
||||
$('wizBack').disabled = wizStep === 1;
|
||||
$('wizNext').textContent = wizStep === 4 ? (demoEl.checked || $('wizDemo').checked ? 'Demo starten' : 'Lua installieren & starten') : 'Weiter';
|
||||
if (wizStep === 2) checkFwl();
|
||||
}
|
||||
function openWizard() {
|
||||
wiz.classList.remove('hidden');
|
||||
$('wizPath').value = xpPath.value || '';
|
||||
$('wizDemo').checked = demoEl.checked;
|
||||
validateWizPath();
|
||||
showStep(1);
|
||||
}
|
||||
function closeWizard() { wiz.classList.add('hidden'); localStorage.setItem('setupDone', '1'); }
|
||||
|
||||
async function validateWizPath() {
|
||||
const p = $('wizPath').value.trim();
|
||||
const h = $('wizPathHint');
|
||||
if (!p) { h.textContent = 'Leer = Demo-Modus möglich.'; h.className = 'hint'; return; }
|
||||
const ok = await invoke('valid_xplane_path', { path: p });
|
||||
h.textContent = ok ? '✓ X-Plane erkannt' : '⚠ kein „Resources/default data" — Pfad prüfen';
|
||||
h.className = 'hint ' + (ok ? 'ok' : 'bad');
|
||||
}
|
||||
async function checkFwl() {
|
||||
const el = $('wizFwl'); const p = $('wizPath').value.trim();
|
||||
if (!p) { el.textContent = '— (kein X-Plane gewählt; im Demo-Modus nicht nötig)'; el.className = 'wiz-status'; return; }
|
||||
const present = await invoke('flywithlua_present', { path: p });
|
||||
el.textContent = present ? '✓ FlyWithLua ist installiert' : '✗ FlyWithLua nicht gefunden';
|
||||
el.className = 'wiz-status ' + (present ? 'ok' : 'bad');
|
||||
}
|
||||
|
||||
$('setupBtn').addEventListener('click', openWizard);
|
||||
$('wizClose').addEventListener('click', closeWizard);
|
||||
$('wizBrowse').addEventListener('click', async () => {
|
||||
try { const dir = await T.dialog.open({ directory: true, multiple: false, title: 'X-Plane 12 Ordner wählen' });
|
||||
if (dir) { $('wizPath').value = dir; validateWizPath(); } } catch (e) { appendLog('dialog: ' + e); }
|
||||
});
|
||||
$('wizPath').addEventListener('input', validateWizPath);
|
||||
$('wizFwlCheck').addEventListener('click', checkFwl);
|
||||
$('wizFwlLink').addEventListener('click', (e) => { e.preventDefault(); openUrl(FWL_URL); });
|
||||
$('wizDemo').addEventListener('change', () => { demoEl.checked = $('wizDemo').checked; showStep(wizStep); });
|
||||
$('wizBack').addEventListener('click', () => showStep(wizStep - 1));
|
||||
$('wizNext').addEventListener('click', async () => {
|
||||
if (wizStep < 4) { showStep(wizStep + 1); return; }
|
||||
// final step: carry the chosen path/demo to the main controls and start
|
||||
xpPath.value = $('wizPath').value.trim();
|
||||
demoEl.checked = $('wizDemo').checked;
|
||||
await validatePath();
|
||||
$('wizResult').textContent = 'Starte Server …'; $('wizResult').className = 'wiz-status';
|
||||
closeWizard();
|
||||
if (!running) startBtn.click();
|
||||
});
|
||||
|
||||
init();
|
||||
// Offer the wizard automatically on the very first launch.
|
||||
if (!localStorage.getItem('setupDone')) setTimeout(openWizard, 400);
|
||||
|
||||
+31
-4
@@ -19,7 +19,7 @@ html, body { margin: 0; height: 100%; }
|
||||
body {
|
||||
background: var(--bg);
|
||||
color: var(--txt);
|
||||
font-family: -apple-system, BlinkMacSystemFont, "SF Pro Text", "Segoe UI", Roboto, sans-serif;
|
||||
font-family: -apple-system, BlinkMacSystemFont, "SF Pro Text", "Segoe UI", "Inter", "Cantarell", "Ubuntu", "Noto Sans", Roboto, sans-serif;
|
||||
font-size: 13px; user-select: none; -webkit-font-smoothing: antialiased;
|
||||
}
|
||||
.panel { display: flex; flex-direction: column; height: 100vh; padding: 16px; gap: 14px; }
|
||||
@@ -69,18 +69,18 @@ input:focus { outline: none; border-color: var(--green); box-shadow: 0 0 0 3px r
|
||||
|
||||
.live.hidden { display: none; }
|
||||
.url-row { display: flex; gap: 8px; align-items: center; }
|
||||
.url-row code { flex: 1; background: var(--bg); border: 1px solid var(--line); color: var(--green); border-radius: 7px; padding: 10px 12px; font-size: 16px; font-weight: 600; letter-spacing: .3px; user-select: text; font-family: ui-monospace, "SF Mono", Menlo, monospace; }
|
||||
.url-row code { flex: 1; background: var(--bg); border: 1px solid var(--line); color: var(--green); border-radius: 7px; padding: 10px 12px; font-size: 16px; font-weight: 600; letter-spacing: .3px; user-select: text; font-family: ui-monospace, "SF Mono", Menlo, "JetBrains Mono", "DejaVu Sans Mono", "Noto Sans Mono", monospace; }
|
||||
.quick { display: flex; gap: 6px; }
|
||||
.quick .btn { flex: 1; }
|
||||
|
||||
.diag { margin-top: 10px; border-top: 1px solid var(--line-soft); padding-top: 8px; display: flex; flex-direction: column; gap: 5px; }
|
||||
.diag-row { display: flex; justify-content: space-between; font-size: 12px; color: var(--mut); }
|
||||
.diag-row b { color: var(--txt2); font-weight: 600; }
|
||||
.diag-row b.ok { color: var(--green); } .diag-row b.warn { color: var(--amber); }
|
||||
.diag-row b.ok { color: var(--green); } .diag-row b.warn { color: var(--amber); } .diag-row b.bad { color: #ff6b6b; }
|
||||
|
||||
.log-wrap { background: var(--bg2); border: 1px solid var(--line-soft); border-radius: 12px; padding: 6px 12px; }
|
||||
.log-wrap summary { color: var(--mut); font-size: 12px; cursor: pointer; padding: 4px 0; }
|
||||
#log { margin: 6px 0 2px; max-height: 140px; overflow-y: auto; font-family: ui-monospace, "SF Mono", Menlo, monospace; font-size: 11px; color: var(--mut); white-space: pre-wrap; }
|
||||
#log { margin: 6px 0 2px; max-height: 140px; overflow-y: auto; font-family: ui-monospace, "SF Mono", Menlo, "JetBrains Mono", "DejaVu Sans Mono", "Noto Sans Mono", monospace; font-size: 11px; color: var(--mut); white-space: pre-wrap; }
|
||||
|
||||
.ft { display: flex; align-items: center; justify-content: space-between; color: var(--mut); font-size: 12px; }
|
||||
.link { background: none; border: none; color: var(--green); cursor: pointer; font-size: 12px; font-family: inherit; }
|
||||
@@ -104,3 +104,30 @@ input:focus { outline: none; border-color: var(--green); box-shadow: 0 0 0 3px r
|
||||
.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; }
|
||||
|
||||
/* header setup button sits between brand and status */
|
||||
.hd { display: flex; align-items: center; gap: 10px; }
|
||||
.hd #setupBtn { margin-left: auto; }
|
||||
.hd #status { margin-left: 8px; }
|
||||
|
||||
/* first-run setup wizard */
|
||||
.wiz { position: fixed; inset: 0; background: rgba(0,0,0,.55); display: flex; align-items: center; justify-content: center; z-index: 50; }
|
||||
.wiz.hidden { display: none; }
|
||||
.wiz-box { width: min(440px, 92vw); background: var(--bg2); border: 1px solid var(--line); border-radius: 14px; padding: 18px; box-shadow: 0 20px 60px rgba(0,0,0,.5); }
|
||||
.wiz-head { display: flex; align-items: center; gap: 10px; margin-bottom: 12px; }
|
||||
.wiz-head b { font-size: 15px; }
|
||||
.wiz-steps { display: flex; gap: 6px; margin-left: auto; }
|
||||
.wiz-steps span { width: 22px; height: 22px; border-radius: 50%; display: grid; place-items: center; font-size: 11px; background: var(--bg3); color: var(--mut); }
|
||||
.wiz-steps span.on { background: var(--green); color: #042b10; font-weight: 700; }
|
||||
.wiz-x { background: none; border: none; color: var(--mut); font-size: 16px; cursor: pointer; }
|
||||
.wiz-step h3 { margin: 0 0 8px; font-size: 14px; color: var(--txt); }
|
||||
.wiz-step p { margin: 0 0 10px; color: var(--txt2); font-size: 13px; line-height: 1.45; }
|
||||
.wiz-step .wiz-sub { color: var(--mut); font-size: 12px; }
|
||||
.wiz-step a { color: var(--green); }
|
||||
.wiz-step code { background: var(--bg3); padding: 1px 5px; border-radius: 4px; font-size: 12px; }
|
||||
.wiz-ol { margin: 0 0 10px; padding-left: 20px; color: var(--txt2); font-size: 13px; line-height: 1.6; }
|
||||
.wiz-status { background: var(--bg3); border: 1px solid var(--line-soft); border-radius: 8px; padding: 8px 10px; font-size: 13px; margin: 8px 0; }
|
||||
.wiz-status.ok { color: var(--green); border-color: #1d4a2c; }
|
||||
.wiz-status.bad { color: #ff6b6b; border-color: #5a2424; }
|
||||
.wiz-foot { display: flex; justify-content: space-between; gap: 10px; margin-top: 16px; }
|
||||
.wiz-foot .btn { flex: 1; }
|
||||
|
||||
@@ -36,3 +36,26 @@ desktop app sets it to the bundled scripts; otherwise it finds `plugins/` itself
|
||||
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).
|
||||
|
||||
## Cessna Citation X (Honeywell Primus 2000)
|
||||
|
||||
The app ships three switchable **cockpit profiles** (sidebar dropdown): the
|
||||
Garmin **G1000**, the **Citation X**, and a **GA steam** panel. The Citation
|
||||
profile recreates the Primus 2000 suite line-for-line from the X-Plane Citation
|
||||
X manual — **PFD** (attitude, speed/alt tapes with Vmo barber-pole + Vfe + AOA,
|
||||
HSI with VOR/ADF bearing pointers, CDI, VSI, FD bars, radar altimeter,
|
||||
minimums), **MFD** (Honeywell arc map, FMS route, TCAS, terrain/WX, ETE/SAT/TAS/
|
||||
GSPD, ET/FT timer, V-SPEEDS card), **EICAS** (twin N1/ITT/oil, fuel, electrical,
|
||||
hydraulics, slats, stab-trim, flaps, CAS), the **autopilot/flight-guidance**
|
||||
controller (HDG/NAV/APP/BC · ALT/VNAV/BANK/STBY · FLC/VS · AP/YD/M-TRIM/PFD-SEL
|
||||
+ pitch wheel), the **Radio Management Unit**, and the **Nav Source Selector**.
|
||||
|
||||
Integration:
|
||||
- **Avionics (PFD/MFD/EICAS/AP/RMU):** every value is a universal X-Plane
|
||||
dataref / command streamed live by the bridge over the **Web API** — no Lua
|
||||
needed (N1/N2/ITT, radar-alt, AOA, hydraulics, trim, flaps/slats/gear, control
|
||||
positions, ADF, mach, yaw-damper, the per-mode `*_status` AFCS annunciation…).
|
||||
- **FMS / CDU:** the same **`fms-sync.lua`** bridges the web CDU ↔ the in-sim
|
||||
FMS. It uses the aircraft-agnostic XPLM FMS SDK, so it drives the Citation X's
|
||||
built-in FMS exactly as it does the G1000's — load/build a plan on a tablet and
|
||||
the Citation flies it; build it in the sim and it shows on every tablet.
|
||||
|
||||
Executable
+94
@@ -0,0 +1,94 @@
|
||||
#!/usr/bin/env bash
|
||||
# Build the Linux artifacts (AppImage + .deb) WITHOUT Docker and WITHOUT
|
||||
# recompiling the Rust launcher.
|
||||
#
|
||||
# WHY: this app's launcher (Tauri/Rust) barely ever changes — what changes is the
|
||||
# cockpit itself (web/ JSX), which ships as a *resource* the Bun sidecar serves at
|
||||
# runtime. So a release is really just: rebuild the web cockpit, drop it into the
|
||||
# already-compiled bundle, and repack. That's seconds, not a Docker cross-build.
|
||||
#
|
||||
# It reuses three things produced by a prior full `tauri build`:
|
||||
# * the compiled launcher + GTK/WebKit libs in the AppDir
|
||||
# * the cached linuxdeploy appimage packer (~/.cache/tauri/...)
|
||||
# * the seed .deb (for its control metadata + file tree)
|
||||
# and refreshes the cockpit (usr/lib/X-Plane Cockpit/web) + Lua plugins in both.
|
||||
#
|
||||
# Seed once (native, no Docker) if the AppDir is missing:
|
||||
# scripts/prep-desktop.sh
|
||||
# npx --prefix desktop tauri build --target x86_64-unknown-linux-gnu --bundles appimage,deb
|
||||
# Thereafter just run this script for every web-only change.
|
||||
#
|
||||
# CAVEAT: the launcher binary is reused as-is, so the *version it reports itself*
|
||||
# (used by the auto-updater) is whatever it was last compiled with — not
|
||||
# necessarily $VERSION. The cockpit features are unaffected (they come from the
|
||||
# refreshed web resources). If you bump the version AND rely on the updater,
|
||||
# recompile the launcher once with the tauri build line above.
|
||||
set -euo pipefail
|
||||
ROOT="$(cd "$(dirname "$0")/.." && pwd)"; cd "$ROOT"
|
||||
die(){ echo "!! $*" >&2; exit 1; }
|
||||
|
||||
TARGET=x86_64-unknown-linux-gnu
|
||||
BUNDLE="$ROOT/target-linux/$TARGET/release/bundle"
|
||||
APPDIR="$BUNDLE/appimage/X-Plane Cockpit.AppDir"
|
||||
RESDIR="$APPDIR/usr/lib/X-Plane Cockpit"
|
||||
PLUGIN="$HOME/.cache/tauri/linuxdeploy-plugin-appimage.AppImage"
|
||||
SIDECAR="$ROOT/desktop/src-tauri/binaries/xpbridge-$TARGET"
|
||||
PKGNAME="X-Plane Cockpit"
|
||||
VERSION="$(node -p "require('$ROOT/desktop/src-tauri/tauri.conf.json').version")"
|
||||
|
||||
echo "==> X-Plane Cockpit Linux repack — v$VERSION (no Docker, no Rust recompile)"
|
||||
|
||||
# ---- preflight -----------------------------------------------------------
|
||||
[[ -d "$APPDIR" ]] || die "no prebuilt AppDir: $APPDIR — seed one full tauri build first (see header)"
|
||||
[[ -x "$PLUGIN" ]] || die "missing appimage packer: $PLUGIN — run one tauri appimage build to fetch it"
|
||||
[[ -f "$SIDECAR" ]] || die "missing sidecar: $SIDECAR — run scripts/prep-desktop.sh"
|
||||
command -v ar >/dev/null || die "'ar' (binutils) required to assemble the .deb"
|
||||
command -v tar >/dev/null || die "'tar' required"
|
||||
|
||||
SIGN=0
|
||||
[[ -f desktop/.tauri-signing.key && -f desktop/.tauri-signing.pw ]] && SIGN=1
|
||||
sign(){ # $1 = artifact; writes <artifact>.sig next to it
|
||||
if [[ $SIGN == 1 ]]; then
|
||||
TAURI_SIGNING_PRIVATE_KEY="$(cat desktop/.tauri-signing.key)" \
|
||||
TAURI_SIGNING_PRIVATE_KEY_PASSWORD="$(cat desktop/.tauri-signing.pw)" \
|
||||
npx --prefix desktop tauri signer sign "$1" >/dev/null
|
||||
echo " signed: $(basename "$1").sig"
|
||||
else
|
||||
rm -f "$1.sig"
|
||||
fi
|
||||
}
|
||||
|
||||
# ---- 1. build the web cockpit, refresh repo resources --------------------
|
||||
echo "==> building web cockpit (vite)"
|
||||
( cd web && npm run build >/dev/null )
|
||||
echo "==> refreshing desktop/src-tauri/resources"
|
||||
rm -rf "desktop/src-tauri/resources/web"; mkdir -p "desktop/src-tauri/resources/web"
|
||||
cp -R web/dist/. "desktop/src-tauri/resources/web/"
|
||||
rm -rf "desktop/src-tauri/resources/plugins"; mkdir -p "desktop/src-tauri/resources/plugins"
|
||||
cp plugins/*.lua "desktop/src-tauri/resources/plugins/"
|
||||
|
||||
# ---- 2. refresh the cockpit inside the prebuilt AppDir -------------------
|
||||
echo "==> updating bundled cockpit inside AppDir"
|
||||
rm -rf "$RESDIR/web"; mkdir -p "$RESDIR/web"; cp -R web/dist/. "$RESDIR/web/"
|
||||
rm -rf "$RESDIR/plugins"; mkdir -p "$RESDIR/plugins"; cp plugins/*.lua "$RESDIR/plugins/"
|
||||
# the AppDir's sidecar copy may be a patchelf-corrupted one — restore the pristine
|
||||
cp -f "$SIDECAR" "$APPDIR/usr/bin/xpbridge"; chmod +x "$APPDIR/usr/bin/xpbridge"
|
||||
|
||||
# ---- 3. pack the AppImage (cached linuxdeploy plugin, no patchelf) -------
|
||||
OUTIMG="$BUNDLE/appimage/${PKGNAME}_${VERSION}_amd64.AppImage"
|
||||
echo "==> packing AppImage -> $(basename "$OUTIMG")"
|
||||
rm -f "$OUTIMG"
|
||||
APPIMAGE_EXTRACT_AND_RUN=1 NO_STRIP=1 ARCH=x86_64 LDAI_OUTPUT="$OUTIMG" \
|
||||
"$PLUGIN" --appdir "$APPDIR" >/dev/null
|
||||
[[ -f "$OUTIMG" ]] || die "AppImage packing produced nothing"
|
||||
chmod +x "$OUTIMG"
|
||||
sign "$OUTIMG"
|
||||
echo " AppImage: $(du -h "$OUTIMG" | cut -f1)"
|
||||
|
||||
# The .deb is intentionally NOT built: the AppImage is the single self-contained,
|
||||
# self-updating artifact (the Tauri Linux updater swaps the AppImage in place; it
|
||||
# never uses a .deb). If you ever want a .deb too, run a full native tauri build
|
||||
# with --bundles appimage,deb.
|
||||
|
||||
echo "==> done. artifact:"
|
||||
ls -1 "$OUTIMG"
|
||||
@@ -79,6 +79,15 @@ async function fetchAllByName(resource, names) {
|
||||
|
||||
// ---- X-Plane connection ---------------------------------------------------
|
||||
async function resolveIds() {
|
||||
// one-shot diagnostic: probe a universal dataref so the log shows the API's
|
||||
// real response shape (helps when a version/format mismatch yields 0 matches).
|
||||
try {
|
||||
const probe = 'sim/cockpit2/gauges/indicators/airspeed_kts_pilot';
|
||||
const u = `${REST}/datarefs?filter[name]=${encodeURIComponent(probe)}`;
|
||||
const r = await fetch(u, { headers: { Accept: 'application/json' } });
|
||||
const txt = await r.text();
|
||||
log(`resolve-probe: GET ${u} -> HTTP ${r.status}; body ${txt.slice(0, 160)}`);
|
||||
} catch (e) { log(`resolve-probe failed: ${e.message}`); }
|
||||
const drefNames = Object.values(DATAREFS);
|
||||
const cmdNames = Object.values(COMMANDS);
|
||||
state.drefNameToId = await fetchAllByName('datarefs', [
|
||||
@@ -146,11 +155,18 @@ function connectXPlane() {
|
||||
}
|
||||
});
|
||||
|
||||
// 'error' and 'close' both fire on a failed socket — guard so each socket
|
||||
// schedules exactly ONE reconnect (otherwise attempts double every cycle and
|
||||
// the process leaks sockets/timers until it OOMs).
|
||||
let downHandled = false;
|
||||
const onDown = (why) => {
|
||||
if (downHandled) return;
|
||||
downHandled = true;
|
||||
if (state.xpConnected) log(`X-Plane disconnected (${why})`);
|
||||
state.xpConnected = false;
|
||||
broadcast({ type: 'status', xpConnected: false });
|
||||
if (state.xpSocket === sock) state.xpSocket = null;
|
||||
try { sock.removeAllListeners(); sock.terminate?.(); } catch {}
|
||||
setTimeout(connectXPlane, 3000);
|
||||
};
|
||||
sock.on('close', () => onDown('close'));
|
||||
@@ -325,6 +341,30 @@ function startDemo() {
|
||||
// engine strip (arrays, like the sim)
|
||||
engRpm: [2410], fuelFlow: [0.0072], oilTemp: [88], oilPress: [52], egt: [720],
|
||||
fuelQty: [60, 58], volts: [process.env.DEMO_ALERT ? 23.4 : 28.0, 27.8], amps: [-1.5], genAmps: [20.5], engHrs: 5040,
|
||||
fuelTot: 118 * 2.72, fuelMax: 144 * 2.72, // fuel totalizer: 118 of 144 gal (kg) — SYSTEM keys adjust
|
||||
// TRAFFIC (TCAS) + NEXRAD demo data so those map overlays are demonstrable.
|
||||
// relAlt = hundreds of ft vs own ship; vs +/- = climb/descend; thr = TA/RA.
|
||||
traffic: [
|
||||
{ lat: 47.52, lon: -122.28, relAlt: 12, vs: 1, thr: 0 },
|
||||
{ lat: 47.40, lon: -122.45, relAlt: -8, vs: -1, thr: 1 },
|
||||
{ lat: 47.47, lon: -122.18, relAlt: 2, vs: 0, thr: 2 },
|
||||
],
|
||||
wxCells: [ // synthetic NEXRAD precip: lat,lon,radiusNm,level(1 green·2 yellow·3 red)
|
||||
{ lat: 47.7, lon: -122.0, r: 8, lvl: 2 }, { lat: 47.75, lon: -121.9, r: 5, lvl: 3 },
|
||||
{ lat: 47.2, lon: -122.6, r: 10, lvl: 1 }, { lat: 47.25, lon: -122.5, r: 6, lvl: 2 },
|
||||
],
|
||||
|
||||
// --- Citation X demo (twin turbofan @ FL280 cruise) ---
|
||||
n1: [88.4, 88.1], n2: [94.6, 94.5], itt: [702, 698],
|
||||
radioAlt: 5500, mach: 0.74, aoa: 2.4,
|
||||
adf1Brg: 135, adf2Brg: 295, adf1: 375, adf2: 290,
|
||||
hydPress: [3120, 3120], elevTrim: -0.25, flapRatio: 0, flapDeploy: 0,
|
||||
slatRatio: 0, gearHandle: 0, gearDeploy: [0, 0, 0], speedbrake: 0, parkBrake: 0,
|
||||
ailDefl: 0.04, elevDefl: -0.08, rudDefl: 0.02,
|
||||
battVolt: [28.0, 27.8], battTemp: [24, 24], ydOn: 1,
|
||||
// override the GA single-engine arrays with twin-jet values
|
||||
oilTemp: [88, 87], oilPress: [52, 53], fuelFlow: [0.366, 0.364], fuelQty: [1500, 1500],
|
||||
egt: [702, 698], volts: [28.0, 27.8], amps: [-2, -2], genAmps: [120, 118],
|
||||
});
|
||||
// a sample plan so the map/FMS show something in demo mode
|
||||
fp.setPlan({ name: 'DEMO', waypoints: [
|
||||
|
||||
@@ -96,6 +96,8 @@ 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',
|
||||
fuelTot: 'sim/cockpit2/fuel/fuel_totalizer_sum_kg', // pilot-set fuel totalizer (remaining, kg)
|
||||
fuelMax: 'sim/aircraft/weight/acf_m_fuel_tot', // max fuel capacity (kg) — for RST FUEL
|
||||
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
|
||||
@@ -124,6 +126,45 @@ export const DATAREFS = {
|
||||
flcStatus: 'sim/cockpit2/autopilot/speed_status',
|
||||
gsStatus: 'sim/cockpit2/autopilot/glideslope_status',
|
||||
vnavStatus: 'sim/cockpit2/autopilot/vnav_status',
|
||||
|
||||
// ====================================================================
|
||||
// CESSNA CITATION X (model 750) — twin Rolls-Royce AE3007C turbofans.
|
||||
// Honeywell Primus 2000 suite: PFD / MFD / EICAS / dual CDU.
|
||||
// All of these are universal sim datarefs, streamed live with no Lua;
|
||||
// only the FMS flight-plan needs the FlyWithLua bridge (fms-sync.lua).
|
||||
// ====================================================================
|
||||
// --- engine (arrays index 0 = LH, 1 = RH) ---
|
||||
n1: 'sim/cockpit2/engine/indicators/N1_percent', // Fan RPM % (EICAS FAN%)
|
||||
n2: 'sim/cockpit2/engine/indicators/N2_percent', // Core RPM % (standby panel)
|
||||
itt: 'sim/cockpit2/engine/indicators/ITT_deg_C', // Interstage Turbine Temp °C
|
||||
fuelPress: 'sim/cockpit2/engine/indicators/fuel_pressure_psi',
|
||||
throttle: 'sim/cockpit2/engine/actuators/throttle_ratio', // per-engine commanded thrust
|
||||
// --- Citation PFD extras ---
|
||||
radioAlt: 'sim/cockpit2/gauges/indicators/radio_altimeter_height_ft_pilot', // RA (<2500 ft AGL)
|
||||
mach: 'sim/cockpit2/gauges/indicators/mach_pilot', // PFD Mach / FLC target
|
||||
aoa: 'sim/flightmodel/position/alpha', // angle of attack (deg) → normalised
|
||||
adf1Brg: 'sim/cockpit2/radios/indicators/adf1_relative_bearing_deg',
|
||||
adf2Brg: 'sim/cockpit2/radios/indicators/adf2_relative_bearing_deg',
|
||||
adf1: 'sim/cockpit2/radios/actuators/adf1_frequency_hz',
|
||||
adf2: 'sim/cockpit2/radios/actuators/adf2_frequency_hz',
|
||||
// --- EICAS systems ---
|
||||
hydPress: 'sim/cockpit2/hydraulics/indicators/hydraulic_pressure_psi', // array A/B (PSI)
|
||||
elevTrim: 'sim/cockpit2/controls/elevator_trim', // -1..1 → STAB deg
|
||||
flapRatio: 'sim/cockpit2/controls/flap_ratio', // 0..1 → SLAT/5/15/FULL
|
||||
flapDeploy: 'sim/flightmodel2/controls/flap1_deploy_ratio', // actual trailing-edge flap
|
||||
slatRatio: 'sim/flightmodel2/controls/slat_ratio', // leading-edge slat status
|
||||
gearHandle: 'sim/cockpit2/controls/gear_handle_down', // 0 up / 1 down
|
||||
gearDeploy: 'sim/flightmodel2/gear/deploy_ratio', // array: per-gear 0..1
|
||||
speedbrake: 'sim/cockpit2/controls/speedbrake_ratio',
|
||||
parkBrake: 'sim/cockpit2/controls/parking_brake_ratio',
|
||||
// control-position graphic (CTRL POS page): commanded surface ratios -1..1
|
||||
ailDefl: 'sim/cockpit2/controls/yoke_roll_ratio',
|
||||
elevDefl: 'sim/cockpit2/controls/yoke_pitch_ratio',
|
||||
rudDefl: 'sim/cockpit2/controls/yoke_heading_ratio',
|
||||
battVolt: 'sim/cockpit2/electrical/battery_voltage', // array per battery
|
||||
battTemp: 'sim/cockpit2/electrical/battery_temp_C', // ELEC page (append)
|
||||
// --- yaw damper / mach-trim annunciation (Citation AP YD / M TRIM) ---
|
||||
ydOn: 'sim/cockpit2/switches/yaw_damper_on',
|
||||
};
|
||||
|
||||
// Datarefs the frontend may WRITE (e.g. turning the heading bug knob).
|
||||
@@ -135,6 +176,12 @@ export const WRITABLE_DATAREFS = {
|
||||
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)
|
||||
fuelTot: 'sim/cockpit2/fuel/fuel_totalizer_sum_kg', // SYSTEM → DEC/INC/RST FUEL adjusts the totalizer
|
||||
// NRST page: load a selected airport's tower/CTAF into COM standby, or a VOR into NAV standby.
|
||||
com1Sb: 'sim/cockpit2/radios/actuators/com1_standby_frequency_hz',
|
||||
com2Sb: 'sim/cockpit2/radios/actuators/com2_standby_frequency_hz',
|
||||
nav1Sb: 'sim/cockpit2/radios/actuators/nav1_standby_frequency_hz',
|
||||
nav2Sb: 'sim/cockpit2/radios/actuators/nav2_standby_frequency_hz',
|
||||
};
|
||||
|
||||
// Commands the frontend may TRIGGER (autopilot mode buttons etc.).
|
||||
@@ -156,6 +203,16 @@ export const COMMANDS = {
|
||||
hdgUp: 'sim/autopilot/heading_up',
|
||||
hdgDown: 'sim/autopilot/heading_down',
|
||||
xpdrIdent: 'sim/transponder/transponder_ident',
|
||||
|
||||
// --- Citation X autopilot (Honeywell Primus) extras ---
|
||||
// The mode buttons reuse the universal AP commands above (hdg/nav/apr/bc/
|
||||
// altHold/flc/vs/vnav). These add the Citation-specific master functions.
|
||||
yawDamper: 'sim/systems/yaw_damper_toggle', // YD button (engages w/ AP too)
|
||||
apStby: 'sim/autopilot/control_wheel_steer', // STBY → basic pitch/roll (CWS), drops modes
|
||||
spdUp: 'sim/autopilot/airspeed_up', // pitch wheel in FLC = target IAS/Mach
|
||||
spdDown: 'sim/autopilot/airspeed_down',
|
||||
vsUp: 'sim/autopilot/vertical_speed_up', // pitch wheel in V/S = target fpm
|
||||
vsDown: 'sim/autopilot/vertical_speed_down',
|
||||
};
|
||||
|
||||
// Per-radio standby tuning (coarse = MHz, fine = kHz) + active/standby flip.
|
||||
|
||||
+5
-1
@@ -167,9 +167,13 @@ async function parseAirways(file) {
|
||||
const b = index.get((p[3] || '').toUpperCase());
|
||||
if (!a || !b) continue; // endpoint not in our database
|
||||
const name = p[p.length - 1];
|
||||
// Field 8 (index 7) = airway layer: 1 = low/Victor, 2 = high/Jet (used by the
|
||||
// MFD AIRWAYS key to show all / Victor-only / Jet-only). Fall back to the name
|
||||
// prefix (V… = low, J… = high) if the field is missing.
|
||||
const lyr = +p[7] === 1 ? 1 : +p[7] === 2 ? 2 : /^J/i.test(name) ? 2 : /^V/i.test(name) ? 1 : 0;
|
||||
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 });
|
||||
arr.push({ la1: a.lat, lo1: a.lon, la2: b.lat, lo2: b.lon, name, lyr });
|
||||
state.awy++;
|
||||
}
|
||||
}
|
||||
|
||||
+124
-25
@@ -11,6 +11,13 @@ import DirectTo from './components/DirectTo.jsx';
|
||||
import Proc from './components/Proc.jsx';
|
||||
import FplPage from './components/FplPage.jsx';
|
||||
import AudioPanel from './components/AudioPanel.jsx';
|
||||
import KAP140 from './components/KAP140.jsx';
|
||||
import CitPFD from './components/citation/CitPFD.jsx';
|
||||
import CitMFD from './components/citation/CitMFD.jsx';
|
||||
import CitDuo from './components/citation/CitDuo.jsx';
|
||||
import CitEICAS from './components/citation/CitEICAS.jsx';
|
||||
import CitAP from './components/citation/CitAP.jsx';
|
||||
import CitRMU from './components/citation/CitRMU.jsx';
|
||||
|
||||
// Compact line icons for the nav rail (stroke = currentColor).
|
||||
const ICONS = {
|
||||
@@ -21,29 +28,66 @@ const ICONS = {
|
||||
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',
|
||||
eicas: 'M5 4v14M9 4v14M5 11h4M13 7h5M13 11h5M13 15h5',
|
||||
rmu: 'M4 5h14v12H4zM7 8h8M7 11h8M7 14h4',
|
||||
duo: 'M3 5h7v12H3zM12 5h7v12h-7z',
|
||||
};
|
||||
function Icon({ name }) {
|
||||
return (
|
||||
<svg className="snav-ic" viewBox="0 0 22 22" width="22" height="22" fill="none"
|
||||
stroke="currentColor" strokeWidth="1.6" strokeLinecap="round" strokeLinejoin="round">
|
||||
<path d={ICONS[name]} />
|
||||
<path d={ICONS[name] || ICONS.mfd} />
|
||||
{name === 'map' && <circle cx="11" cy="8" r="2" />}
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
|
||||
const TABS = [
|
||||
{ id: 'pfd', label: 'PFD' },
|
||||
{ id: 'mfd', label: 'MFD' },
|
||||
{ id: 'map', label: 'Map' },
|
||||
{ id: 'fms', label: 'FMS' },
|
||||
{ id: 'vfr', label: 'VFR' },
|
||||
{ id: 'ap', label: 'Autopilot' },
|
||||
{ id: 'audio', label: 'Audio' },
|
||||
];
|
||||
// Three selectable cockpit profiles. Each maps the app to a different aircraft's
|
||||
// avionics suite, sharing the same bridge/datarefs underneath.
|
||||
const PROFILES = {
|
||||
g1000: {
|
||||
label: 'Garmin G1000', short: 'G1000',
|
||||
tabs: [
|
||||
{ id: 'pfd', label: 'PFD' }, { id: 'mfd', label: 'MFD' }, { id: 'map', label: 'Map' },
|
||||
{ id: 'fms', label: 'FMS' }, { id: 'vfr', label: 'VFR' }, { id: 'ap', label: 'Autopilot' },
|
||||
{ id: 'audio', label: 'Audio' },
|
||||
],
|
||||
},
|
||||
citation: {
|
||||
label: 'Cessna Citation X', short: 'CITATION X',
|
||||
tabs: [
|
||||
{ id: 'duo', label: 'PFD+MFD' }, { id: 'pfd', label: 'PFD' }, { id: 'mfd', label: 'MFD' },
|
||||
{ id: 'eicas', label: 'EICAS' }, { id: 'fms', label: 'CDU/FMS' }, { id: 'ap', label: 'Autopilot' },
|
||||
{ id: 'rmu', label: 'Radios' }, { id: 'map', label: 'Map' },
|
||||
],
|
||||
},
|
||||
ga: {
|
||||
label: 'GA Steam (Bendix/King)', short: 'GA PANEL',
|
||||
tabs: [
|
||||
{ id: 'vfr', label: 'Panel' }, { id: 'ap', label: 'KAP 140' },
|
||||
{ id: 'map', label: 'Map' }, { id: 'audio', label: 'Audio' },
|
||||
],
|
||||
},
|
||||
};
|
||||
|
||||
export default function App() {
|
||||
const xp = useXplane();
|
||||
// Active cockpit profile — persisted; switches the whole avionics suite.
|
||||
const [profile, setProfile] = useState(() => localStorage.getItem('cockpitProfile') || 'g1000');
|
||||
// Citation Nav Source Selector bearing-pointer sources (p24): pointer 1 = cyan
|
||||
// circle, pointer 2 = white diamond. Each OFF/VORn/ADFn/FMSn. Shared PFD↔RMU.
|
||||
const [navSrc, setNavSrc] = useState(() => {
|
||||
try { return JSON.parse(localStorage.getItem('citNavSrc')) || { brg1: 'VOR1', brg2: 'VOR2' }; }
|
||||
catch { return { brg1: 'VOR1', brg2: 'VOR2' }; }
|
||||
});
|
||||
useEffect(() => { localStorage.setItem('citNavSrc', JSON.stringify(navSrc)); }, [navSrc]);
|
||||
const [profMenu, setProfMenu] = useState(false);
|
||||
const PROF = PROFILES[profile] || PROFILES.g1000;
|
||||
const TABS = PROF.tabs;
|
||||
const pickProfile = (p) => {
|
||||
localStorage.setItem('cockpitProfile', p); setProfile(p); setProfMenu(false);
|
||||
const first = PROFILES[p].tabs[0].id; setTab(first); history.replaceState(null, '', `#${first}`);
|
||||
};
|
||||
const [tab, setTab] = useState(() => location.hash.replace('#', '') || 'pfd');
|
||||
// Collapsible nav rail: narrow (icons) ↔ wide (icons + labels), remembered.
|
||||
const [navWide, setNavWide] = useState(() => localStorage.getItem('navWide') === '1');
|
||||
@@ -67,9 +111,16 @@ export default function App() {
|
||||
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';
|
||||
const fpl = win === 'fpl', dto = win === 'dto', proc = win === 'proc', menu = win === 'menu';
|
||||
// MFD map mode (base layer + overlays), switched via the Map-Opt softkeys.
|
||||
const [mapMode, setMapMode] = useState({ base: 'topo' });
|
||||
// VNAV profile control (FPL VNAV keys + Direct-To descent): enabled gates the
|
||||
// profile/chevrons, fpa is the descent angle (°), offsetNm levels off that far
|
||||
// before the waypoint. See FplPage CURRENT VNV PROFILE + PFD chevrons.
|
||||
const [vnavCfg, setVnavCfg] = useState({ enabled: true, fpa: 3, offsetNm: 0 });
|
||||
// Synthetic-vision display options (PFD submenu): PATHWAY draws the flight-plan
|
||||
// route on the 3D terrain; APTSIGNS shows runway/airport labels.
|
||||
const [svtOpts, setSvtOpts] = useState({ pathway: true, aptSigns: true, hrznHdg: 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.
|
||||
@@ -86,6 +137,11 @@ export default function App() {
|
||||
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]);
|
||||
// Keep the active tab valid for the current profile (e.g. after a hash deep-link
|
||||
// into a tab the profile doesn't have).
|
||||
useEffect(() => {
|
||||
if (!TABS.some((t) => t.id === tab)) { const f = TABS[0].id; setTab(f); history.replaceState(null, '', `#${f}`); }
|
||||
}, [profile]); // eslint-disable-line react-hooks/exhaustive-deps
|
||||
const connKind = xp.xpConnected ? 'ok' : xp.connected ? 'warn' : 'bad';
|
||||
const connText = xp.xpConnected ? 'X-PLANE' : xp.connected ? 'NO SIM' : 'OFFLINE';
|
||||
|
||||
@@ -93,11 +149,29 @@ export default function App() {
|
||||
// the display's lower-right (like the real unit), not over the whole app.
|
||||
const dialogs = (
|
||||
<>
|
||||
{dto && <DirectTo xp={xp} onClose={() => setWin(null)} />}
|
||||
{dto && <DirectTo xp={xp} onClose={() => setWin(null)} vnav={vnavCfg} onVnav={setVnavCfg} />}
|
||||
{proc && <Proc xp={xp} onClose={() => setWin(null)} />}
|
||||
{menu && (() => {
|
||||
const wps = xp.flightPlan?.waypoints || [];
|
||||
const act = (fn) => { fn(); setWin(null); };
|
||||
const item = (label, on, dis) => <button className="proc-menu-i" disabled={dis} onClick={() => act(on)}>{label}</button>;
|
||||
return (
|
||||
<div className="gwin-backdrop" onClick={() => setWin(null)}>
|
||||
<div className="dlg proc menu" onClick={(e) => e.stopPropagation()}>
|
||||
<div className="dlg-head">PAGE MENU</div>
|
||||
<div className="proc-menu">
|
||||
{item('INVERT FLIGHT PLAN', () => xp.fp.set({ name: 'ACTIVE', waypoints: wps.slice().reverse(), activeLeg: 1 }), wps.length < 2)}
|
||||
{item('STORE FLIGHT PLAN', () => xp.fp.export('WEBFPL'), wps.length < 2)}
|
||||
{item('DELETE FLIGHT PLAN', () => xp.fp.clear(), wps.length < 1)}
|
||||
{item('CANCEL', () => {})}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})()}
|
||||
{fpl && (
|
||||
<div className="gwin-backdrop" onClick={() => setWin(null)}>
|
||||
<div onClick={(e) => e.stopPropagation()}><FplPage xp={xp} onClose={() => setWin(null)} /></div>
|
||||
<div onClick={(e) => e.stopPropagation()}><FplPage xp={xp} onClose={() => setWin(null)} vnav={vnavCfg} onVnav={setVnavCfg} /></div>
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
@@ -106,10 +180,22 @@ export default function App() {
|
||||
return (
|
||||
<div className={`app ${navWide ? 'nav-wide' : 'nav-narrow'}`}>
|
||||
<aside className="sidebar">
|
||||
<button className="sb-top" onClick={toggleNav} title="Menü ein-/ausklappen">
|
||||
<span className="brand">G<span>1000</span></span>
|
||||
<span className="sb-chev">{navWide ? '◂' : '▸'}</span>
|
||||
<button className="sb-top" onClick={() => setProfMenu((v) => !v)} title="Cockpit-Profil wählen">
|
||||
<span className="brand">{PROF.short}</span>
|
||||
<span className="sb-chev">{profMenu ? '▴' : '▾'}</span>
|
||||
</button>
|
||||
{profMenu && (
|
||||
<div className="prof-menu">
|
||||
{Object.entries(PROFILES).map(([id, p]) => (
|
||||
<button key={id} className={`prof-i ${id === profile ? 'on' : ''}`} onClick={() => pickProfile(id)}>
|
||||
{p.label}
|
||||
</button>
|
||||
))}
|
||||
<button className="prof-collapse" onClick={() => { setProfMenu(false); toggleNav(); }}>
|
||||
{navWide ? '◂ Menü einklappen' : '▸ Menü ausklappen'}
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
<nav className="snav">
|
||||
{TABS.map((t) => (
|
||||
<button key={t.id} className={tab === t.id ? 'snav-i active' : 'snav-i'}
|
||||
@@ -133,31 +219,44 @@ export default function App() {
|
||||
</aside>
|
||||
|
||||
<main className="screen">
|
||||
{tab === 'pfd' && (
|
||||
{/* ---- Garmin G1000 suite ---- */}
|
||||
{profile === 'g1000' && tab === 'pfd' && (
|
||||
<Bezel variant="pfd" xp={xp} knobMode={knobMode} svt3d={svt3d} onToggleSvt={() => setSvt3d((v) => !v)}
|
||||
svtOpts={svtOpts} onSvtOpt={setSvtOpts}
|
||||
inset={inset} onSetInset={setInset} insetMode={insetMode} onInsetMode={setInsetMode}
|
||||
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)}
|
||||
alerts={alerts} onToggleAlerts={() => toggleWin('alerts')} onProc={() => toggleWin('proc')} onFpl={() => toggleWin('fpl')} onMenu={() => toggleWin('menu')} 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)}
|
||||
<PFD xp={xp} 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} />
|
||||
minimums={minimums} onMinimums={setMinimums} flightPlan={xp.flightPlan} fp={xp.fp} vnav={vnavCfg} svtOpts={svtOpts} />
|
||||
{dialogs}
|
||||
</Bezel>
|
||||
)}
|
||||
{tab === 'mfd' && (
|
||||
<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} />
|
||||
{profile === 'g1000' && tab === 'mfd' && (
|
||||
<Bezel variant="mfd" xp={xp} knobMode={knobMode} mapMode={mapMode} onMapMode={setMapMode} onDirect={() => toggleWin('dto')} onProc={() => toggleWin('proc')} onFms={cycleMfd} onFpl={() => setMfdPage('fpl')} onMenu={() => toggleWin('menu')} onClr={() => setWin(null)}>
|
||||
<MFD values={xp.values} flightPlan={xp.flightPlan} fp={xp.fp} mapMode={mapMode} page={mfdPage} onCycle={cycleMfd} xp={xp} vnav={vnavCfg} onVnav={setVnavCfg} />
|
||||
{dialogs}
|
||||
</Bezel>
|
||||
)}
|
||||
|
||||
{/* ---- Cessna Citation X suite (Honeywell Primus 2000) ---- */}
|
||||
{profile === 'citation' && tab === 'duo' && <CitDuo xp={xp} navSrc={navSrc} />}
|
||||
{profile === 'citation' && tab === 'pfd' && <CitPFD xp={xp} navSrc={navSrc} />}
|
||||
{profile === 'citation' && tab === 'mfd' && <CitMFD xp={xp} />}
|
||||
{profile === 'citation' && tab === 'eicas' && <CitEICAS xp={xp} />}
|
||||
{profile === 'citation' && tab === 'ap' && <CitAP xp={xp} />}
|
||||
{profile === 'citation' && tab === 'rmu' && <CitRMU xp={xp} navSrc={navSrc} onNavSrc={setNavSrc} />}
|
||||
|
||||
{/* ---- shared tabs ---- */}
|
||||
{tab === 'map' && <MapView values={xp.values} flightPlan={xp.flightPlan} fp={xp.fp} />}
|
||||
{tab === 'fms' && <CDU xp={xp} />}
|
||||
{tab === 'fms' && <CDU xp={xp} vnav={vnavCfg} onVnav={setVnavCfg} />}
|
||||
{tab === 'vfr' && <VFR xp={xp} />}
|
||||
{tab === 'ap' && <AutopilotPanel xp={xp} />}
|
||||
{tab === 'audio' && <AudioPanel xp={xp} />}
|
||||
{tab === 'ap' && profile === 'g1000' && <AutopilotPanel xp={xp} />}
|
||||
{tab === 'ap' && profile === 'ga' && <KAP140 xp={xp} />}
|
||||
</main>
|
||||
{settings && (
|
||||
<div className="dlg-backdrop" onClick={() => setSettings(false)}>
|
||||
|
||||
@@ -0,0 +1,185 @@
|
||||
/* ============================================================================
|
||||
Cessna Citation X — Honeywell Primus 2000 styling.
|
||||
Dark grey bezels, green/cyan/magenta glass, white symbology — matching the
|
||||
Citation X manual's PFD / MFD / EICAS / autopilot / RMU illustrations.
|
||||
============================================================================ */
|
||||
|
||||
/* ---- cockpit-profile selector (sidebar dropdown) ---- */
|
||||
.prof-menu {
|
||||
position: absolute; top: 52px; left: 6px; right: 6px; z-index: 60;
|
||||
background: #11161c; border: 1px solid #2a3138; border-radius: 8px;
|
||||
box-shadow: 0 10px 28px rgba(0,0,0,.6); padding: 6px; display: flex; flex-direction: column; gap: 4px;
|
||||
}
|
||||
.prof-i {
|
||||
text-align: left; padding: 9px 12px; border-radius: 6px; border: 1px solid transparent;
|
||||
background: transparent; color: #cdd6dd; font-size: 13px; cursor: pointer;
|
||||
}
|
||||
.prof-i:hover { background: #1c232b; }
|
||||
.prof-i.on { background: #15324a; color: #7fd4ff; border-color: #1f5f86; }
|
||||
.prof-collapse { margin-top: 4px; padding: 7px; font-size: 12px; color: #8b97a0; background: #0d1217; border: 1px solid #222a31; border-radius: 6px; cursor: pointer; }
|
||||
.brand { letter-spacing: .04em; }
|
||||
|
||||
/* ---- shared Citation screen frame ---- */
|
||||
.cit-screen {
|
||||
width: 100%; height: 100%; display: flex; flex-direction: column;
|
||||
align-items: center; justify-content: center; gap: 14px;
|
||||
background: radial-gradient(circle at 50% 35%, #11161b 0%, #05080b 70%);
|
||||
padding: 18px; box-sizing: border-box;
|
||||
}
|
||||
.cit-pfd, .cit-mfd, .cit-eicas {
|
||||
width: auto; height: 100%; max-height: calc(100vh - 130px); max-width: 100%;
|
||||
background: #000; border: 10px solid #1a1f24; border-radius: 12px;
|
||||
box-shadow: inset 0 0 0 2px #2c333a, 0 8px 30px rgba(0,0,0,.55);
|
||||
font-family: 'Roboto Mono','Consolas',monospace;
|
||||
}
|
||||
.cit-pfd { aspect-ratio: 800 / 940; }
|
||||
.cit-mfd { aspect-ratio: 800 / 940; }
|
||||
.cit-eicas { aspect-ratio: 800 / 940; }
|
||||
.cit-pfd text, .cit-mfd text, .cit-eicas text { font-family: 'Roboto Mono','Consolas',monospace; }
|
||||
|
||||
/* ---- bezel (soft-key / knob strip beneath a display) ---- */
|
||||
.cit-bezel {
|
||||
display: flex; flex-wrap: wrap; align-items: center; justify-content: center; gap: 8px;
|
||||
background: linear-gradient(#23292f,#171c21); border: 1px solid #2c333a; border-radius: 10px;
|
||||
padding: 8px 12px; box-shadow: inset 0 1px 0 #3a424a, 0 4px 12px rgba(0,0,0,.4);
|
||||
}
|
||||
.cit-bz-btn, .cit-sk {
|
||||
min-width: 64px; padding: 8px 12px; font-size: 12px; font-weight: 700; letter-spacing: .04em;
|
||||
color: #d3dbe1; background: linear-gradient(#2b333b,#1d2329); border: 1px solid #39424b;
|
||||
border-radius: 6px; cursor: pointer; font-family: 'Roboto Mono',monospace;
|
||||
}
|
||||
.cit-bz-btn:hover, .cit-sk:hover { background: #313b44; }
|
||||
.cit-bz-btn.on, .cit-sk.on { background: #0e5a2a; border-color: #1b8a43; color: #c9ffd6; box-shadow: 0 0 8px rgba(25,180,80,.5); }
|
||||
.cit-bz-group { display: flex; align-items: center; gap: 4px; padding: 2px 8px; background: #11161b; border: 1px solid #2a3138; border-radius: 6px; }
|
||||
.cit-bz-lbl { font-size: 10px; color: #8b97a0; letter-spacing: .08em; }
|
||||
.cit-bz-val { min-width: 52px; text-align: center; font-size: 13px; color: #7fd4ff; font-family: 'Roboto Mono',monospace; }
|
||||
.cit-bz-knob { width: 30px; padding: 6px 0; font-size: 12px; color: #cdd6dd; background: #232a31; border: 1px solid #39424b; border-radius: 5px; cursor: pointer; }
|
||||
.cit-bz-knob:hover { background: #2e3740; }
|
||||
|
||||
/* ============================================================================
|
||||
AUTOPILOT — Honeywell Primus 2000 Flight Guidance Controller (hardware look)
|
||||
============================================================================ */
|
||||
.citap-screen { gap: 14px; }
|
||||
.citap-refs { display: flex; gap: 30px; color: #cdd6dd; font-family: 'Roboto Mono',monospace; }
|
||||
.citap-refs div { text-align: center; }
|
||||
.citap-refs span { display: block; font-size: 10px; color: #8b97a0; letter-spacing: .08em; }
|
||||
.citap-refs b { font-size: 22px; color: #d24bd2; }
|
||||
.citap-fma {
|
||||
display: flex; gap: 0; background: #05080b; border: 1px solid #2a3138; border-radius: 4px; overflow: hidden;
|
||||
font-family: 'Roboto Mono',monospace; font-weight: 700; min-width: 360px;
|
||||
}
|
||||
.citap-fma span { flex: 1; text-align: center; padding: 6px 14px; font-size: 14px; }
|
||||
.fma-act { color: #16e000; } .fma-arm { color: #fff; }
|
||||
.citap-fma .fma-ap { color: #16e000; border-left: 1px solid #2a3138; border-right: 1px solid #2a3138; }
|
||||
|
||||
/* the controller body: machined dark-grey bezel with screws */
|
||||
.citap-panel {
|
||||
position: relative; display: flex; align-items: stretch; gap: 10px; padding: 20px 26px;
|
||||
background: linear-gradient(#3a4047 0%,#23282e 8%,#1a1e23 92%,#2b3137 100%);
|
||||
border: 1px solid #0c0f12; border-radius: 10px;
|
||||
box-shadow: inset 0 1px 0 rgba(255,255,255,.08), inset 0 0 0 1px #0c0f12, 0 8px 26px rgba(0,0,0,.6);
|
||||
}
|
||||
.citap-panel::before, .citap-panel::after {
|
||||
content: ''; position: absolute; width: 7px; height: 7px; border-radius: 50%;
|
||||
background: radial-gradient(circle at 35% 35%,#5a626a,#1b1f24); top: 7px;
|
||||
}
|
||||
.citap-panel::before { left: 8px; } .citap-panel::after { right: 8px; }
|
||||
.citap-col { display: flex; flex-direction: column; gap: 9px; justify-content: flex-start; }
|
||||
.citap-master { margin-left: 8px; border-left: 1px solid #0c0f12; padding-left: 14px; }
|
||||
|
||||
/* engraved square key with a green annunciator triangle (lit when active) */
|
||||
.citap-btn {
|
||||
position: relative; width: 82px; padding: 9px 8px 9px 20px; text-align: left;
|
||||
font-size: 12px; font-weight: 700; letter-spacing: .06em; color: #e4e9ee;
|
||||
background: linear-gradient(#33393f,#1c2025); border: 1px solid #0e1114;
|
||||
border-radius: 4px; cursor: pointer; font-family: 'Roboto Mono',monospace;
|
||||
box-shadow: inset 0 1px 0 rgba(255,255,255,.10), inset 0 -2px 3px rgba(0,0,0,.5), 0 1px 2px rgba(0,0,0,.6);
|
||||
text-shadow: 0 1px 1px #000;
|
||||
}
|
||||
.citap-btn .citap-arrow {
|
||||
position: absolute; left: 7px; top: 50%; transform: translateY(-50%);
|
||||
width: 0; height: 0; border-top: 5px solid transparent; border-bottom: 5px solid transparent;
|
||||
border-left: 7px solid #2b3137; font-size: 0; line-height: 0; color: transparent;
|
||||
}
|
||||
.citap-btn:hover { background: linear-gradient(#3b424a,#23282e); }
|
||||
.citap-btn:active { box-shadow: inset 0 2px 4px rgba(0,0,0,.7); }
|
||||
.citap-btn.active .citap-arrow { border-left-color: #1fff4e; filter: drop-shadow(0 0 4px #16e000); }
|
||||
.citap-btn.active { color: #fff; }
|
||||
.citap-btn.armed .citap-arrow { border-left-color: #fff; }
|
||||
.citap-btn.armed { color: #fff; }
|
||||
.citap-btn.dim { opacity: .4; cursor: default; }
|
||||
|
||||
/* pitch wheel: NOSE UP/DN labels + a ridged thumbwheel */
|
||||
.citap-wheel { display: flex; flex-direction: column; align-items: center; gap: 4px; justify-content: center; padding: 0 10px; }
|
||||
.citap-wlbl { font-size: 8px; color: #aeb8bf; letter-spacing: .14em; }
|
||||
.citap-wbtn { width: 30px; padding: 3px 0; font-size: 12px; color: #cdd6dd; background: #21262b; border: 1px solid #0e1114; border-radius: 3px; cursor: pointer; }
|
||||
.citap-wheelface {
|
||||
width: 30px; height: 64px; border-radius: 5px; border: 1px solid #0e1114;
|
||||
background: repeating-linear-gradient(#0d0f12,#0d0f12 2px,#3a424a 3px,#22272c 5px,#0d0f12 7px);
|
||||
box-shadow: inset 2px 0 4px rgba(0,0,0,.7), inset -2px 0 4px rgba(0,0,0,.7);
|
||||
}
|
||||
.citap-foot { font-size: 11px; color: #8b97a0; max-width: 660px; text-align: center; line-height: 1.5; }
|
||||
.citap-foot b { color: #7fd4ff; }
|
||||
|
||||
/* ---- combined PFD + MFD (one tablet screen) ---- */
|
||||
.cit-duo { display: flex; width: 100%; height: 100%; background: #05080b; }
|
||||
.cit-duo-half { flex: 1; min-width: 0; height: 100%; display: flex; }
|
||||
.cit-duo .cit-screen { padding: 8px; gap: 8px; }
|
||||
.cit-duo .cit-bezel { padding: 5px 7px; gap: 5px; }
|
||||
.cit-duo .cit-bz-btn, .cit-duo .cit-sk { min-width: 0; padding: 5px 7px; font-size: 10px; }
|
||||
.cit-duo .cit-bz-group { padding: 1px 5px; }
|
||||
.cit-duo .cit-bz-val { min-width: 38px; font-size: 11px; }
|
||||
.cit-duo .cit-bz-knob { width: 22px; padding: 4px 0; }
|
||||
@media (max-width: 900px) { .cit-duo { flex-direction: column; } }
|
||||
|
||||
/* ============================================================================
|
||||
RADIO MANAGEMENT UNIT + Nav source selector
|
||||
============================================================================ */
|
||||
.citrmu-screen { gap: 16px; }
|
||||
.citrmu-wrap { display: flex; gap: 18px; align-items: stretch; }
|
||||
.citrmu-unit {
|
||||
width: 300px; background: #04070a; border: 8px solid #1a1f24; border-radius: 10px; padding: 10px;
|
||||
font-family: 'Roboto Mono',monospace; box-shadow: inset 0 0 0 2px #2c333a;
|
||||
}
|
||||
.citrmu-row { display: flex; gap: 8px; margin-bottom: 8px; }
|
||||
.citrmu-radio, .citrmu-box { flex: 1; background: #0a0e12; border: 1px solid #222a31; border-radius: 5px; padding: 5px 8px; }
|
||||
.citrmu-radio.armed, .citrmu-box.armed { border-color: #16e000; box-shadow: 0 0 8px rgba(22,224,0,.3); }
|
||||
.citrmu-h { font-size: 10px; color: #8b97a0; letter-spacing: .06em; }
|
||||
.citrmu-act { font-size: 20px; color: #16e000; font-weight: 700; }
|
||||
.citrmu-sby { font-size: 15px; color: #7fd4ff; }
|
||||
.citrmu-sub { font-size: 11px; color: #d24bd2; }
|
||||
.citrmu-tcas { color: #16e000; font-size: 13px; text-align: center; padding-top: 4px; border-top: 1px solid #1c232b; }
|
||||
.citrmu-tcas b { color: #fff; }
|
||||
.citrmu-keys { display: flex; gap: 12px; align-items: flex-start; }
|
||||
.citrmu-kcol { display: flex; flex-direction: column; gap: 7px; }
|
||||
.citrmu-btn {
|
||||
min-width: 96px; padding: 9px 10px; font-size: 12px; font-weight: 700; color: #cdd6dd;
|
||||
background: linear-gradient(#2c343c,#1c2228); border: 1px solid #3a434c; border-radius: 6px; cursor: pointer;
|
||||
font-family: 'Roboto Mono',monospace;
|
||||
}
|
||||
.citrmu-btn:hover { background: #333d46; }
|
||||
.citrmu-btn.on { background: #15324a; border-color: #1f6f9e; color: #9fe4ff; }
|
||||
.citrmu-btn.dim { opacity: .45; cursor: default; }
|
||||
.citrmu-tune { display: flex; flex-direction: column; gap: 7px; align-items: center; padding: 6px 10px; background: #11161b; border: 1px solid #2a3138; border-radius: 8px; }
|
||||
.citrmu-tlbl { font-size: 11px; color: #7fd4ff; letter-spacing: .06em; }
|
||||
.citrmu-trow { display: flex; gap: 6px; }
|
||||
.citrmu-trow button, .citrmu-srow button { padding: 7px 10px; font-size: 11px; color: #cdd6dd; background: #232a31; border: 1px solid #39424b; border-radius: 5px; cursor: pointer; }
|
||||
.citrmu-12 { padding: 7px 10px; font-size: 11px; color: #cdd6dd; background: #232a31; border: 1px solid #39424b; border-radius: 5px; cursor: pointer; }
|
||||
.citrmu-srow { display: flex; gap: 6px; }
|
||||
|
||||
.citnav-sel { background: linear-gradient(#23292f,#171c21); border: 1px solid #2c333a; border-radius: 10px; padding: 12px 16px; max-width: 520px; }
|
||||
.citnav-h { font-size: 12px; font-weight: 700; letter-spacing: .08em; color: #8b97a0; text-align: center; margin-bottom: 8px; }
|
||||
.citnav-h2 { font-size: 10px; color: #8b97a0; letter-spacing: .06em; margin: 10px 0 6px; }
|
||||
.citnav-row { display: flex; gap: 8px; justify-content: center; }
|
||||
.citnav-b { min-width: 70px; padding: 9px 12px; font-size: 13px; font-weight: 700; color: #cdd6dd; background: linear-gradient(#2c343c,#1c2228); border: 1px solid #3a434c; border-radius: 6px; cursor: pointer; font-family: 'Roboto Mono',monospace; }
|
||||
.citnav-b.sm { min-width: 56px; font-size: 12px; padding: 7px 10px; }
|
||||
.citnav-b.on { background: #0e5a2a; border-color: #1b8a43; color: #c9ffd6; box-shadow: 0 0 8px rgba(25,190,80,.5); }
|
||||
.citnav-note { font-size: 11px; color: #7a858d; line-height: 1.5; margin-top: 10px; text-align: center; }
|
||||
|
||||
/* EICAS / MFD softkey strips inherit .cit-bezel; just ensure spacing */
|
||||
.cit-eicas-sk, .cit-mfd-sk { min-width: 480px; }
|
||||
|
||||
@media (max-width: 760px) {
|
||||
.citrmu-wrap { flex-direction: column; }
|
||||
.citap-panel { flex-wrap: wrap; }
|
||||
}
|
||||
@@ -28,15 +28,13 @@ 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: ['ENGINE', 'MAP', '', '', '', '', '', '', '', 'DCLTR', '', ''],
|
||||
root: ['SYSTEM', 'MAP', '', '', '', '', '', '', '', 'DCLTR', '', ''],
|
||||
mapopt: ['TRAFFIC', 'PROFILE', 'TOPO', 'TERRAIN', 'AIRWAYS', 'AIRSPACE', 'NEXRAD', 'OSM', '', '', 'BACK', ''],
|
||||
engine: ['DEC FUEL', 'INC FUEL', 'RST FUEL', '', '', '', '', '', '', '', 'BACK', ''],
|
||||
system: ['DEC FUEL', 'INC FUEL', 'RST FUEL', '', '', '', '', '', '', '', 'BACK', ''],
|
||||
};
|
||||
const KG_PER_GAL = 2.72; // fuel totalizer steps in US gallons (matches the EIS readout)
|
||||
|
||||
// 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, dme, onToggleDme, alerts, onToggleAlerts, onDirect, onProc, onFpl, onClr, onFms, mapMode, onMapMode, altHpa, onAltUnit, obs, onObs, knobMode = 'arrows', children }) {
|
||||
export default function Bezel({ variant = 'pfd', xp, svt3d, onToggleSvt, svtOpts, onSvtOpt, inset, onSetInset, insetMode, onInsetMode, nrst, onToggleNrst, tmr, onToggleTmr, dme, onToggleDme, alerts, onToggleAlerts, onDirect, onProc, onFpl, onClr, onMenu, 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
|
||||
@@ -67,18 +65,27 @@ export default function Bezel({ variant = 'pfd', xp, svt3d, onToggleSvt, inset,
|
||||
const cycleDcltr = (setter) => setter && setter((m) => ({ ...m, dcltr: (((m.dcltr || 0) + 1) % 4) }));
|
||||
if (variant === 'mfd') {
|
||||
if (label === 'MAP') setPage('mapopt');
|
||||
else if (label === 'ENGINE') setPage('engine');
|
||||
else if (label === 'SYSTEM') setPage('system');
|
||||
else if (label === 'BACK') setPage('root');
|
||||
else if (label === 'DEC FUEL' && xp) xp.setDataref('fuelTot', Math.max(0, num(xp.values?.fuelTot) - KG_PER_GAL));
|
||||
else if (label === 'INC FUEL' && xp) xp.setDataref('fuelTot', num(xp.values?.fuelTot) + KG_PER_GAL);
|
||||
else if (label === 'RST FUEL' && xp) xp.setDataref('fuelTot', num(xp.values?.fuelMax) || num(xp.values?.fuelTot));
|
||||
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') cycleDcltr(onMapMode);
|
||||
else if (label === 'AIRWAYS') onMapMode && onMapMode((m) => ({ ...m, airways: !m.airways }));
|
||||
else if (label === 'AIRWAYS') onMapMode && onMapMode((m) => ({ ...m, airways: (((m.airways | 0) + 1) % 4) })); // off→all→Victor→Jet
|
||||
else if (label === 'AIRSPACE') onMapMode && onMapMode((m) => ({ ...m, airspace: !m.airspace }));
|
||||
else if (label === 'TRAFFIC') onMapMode && onMapMode((m) => ({ ...m, traffic: !m.traffic }));
|
||||
else if (label === 'NEXRAD') onMapMode && onMapMode((m) => ({ ...m, nexrad: !m.nexrad }));
|
||||
else if (label === 'PROFILE') onMapMode && onMapMode((m) => ({ ...m, profile: !m.profile }));
|
||||
} else {
|
||||
if (label === 'PFD') setPage('pfd');
|
||||
else if (label === 'BACK') setPage({ xpdrcode: 'xpdr', altunit: 'pfd' }[page] || 'root');
|
||||
else if (label === 'SYN TERR') onToggleSvt && onToggleSvt();
|
||||
else if (label === 'PATHWAY') onSvtOpt && onSvtOpt((o) => ({ ...o, pathway: !o.pathway })); // route on the 3D terrain
|
||||
else if (label === 'APTSIGNS') onSvtOpt && onSvtOpt((o) => ({ ...o, aptSigns: !o.aptSigns })); // runway/airport labels in SVT
|
||||
else if (label === 'HRZN HDG') onSvtOpt && onSvtOpt((o) => ({ ...o, hrznHdg: !o.hrznHdg })); // heading marks on the horizon
|
||||
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'); }
|
||||
@@ -115,8 +122,11 @@ export default function Bezel({ variant = 'pfd', xp, svt3d, onToggleSvt, inset,
|
||||
if (variant === 'mfd') return (label === 'TOPO' && mapMode?.base === 'topo')
|
||||
|| (label === 'TERRAIN' && mapMode?.terrain) || (label === 'OSM' && mapMode?.base === 'osm')
|
||||
|| (label === 'DCLTR' && mapMode?.dcltr > 0) || (label === 'AIRWAYS' && mapMode?.airways)
|
||||
|| (label === 'AIRSPACE' && mapMode?.airspace);
|
||||
return (label === 'SYN TERR' && svt3d) || (label === 'INSET' && inset) || (label === 'NRST' && nrst) || (label === 'TMR/REF' && tmr)
|
||||
|| (label === 'AIRSPACE' && mapMode?.airspace) || (label === 'TRAFFIC' && mapMode?.traffic) || (label === 'NEXRAD' && mapMode?.nexrad)
|
||||
|| (label === 'PROFILE' && mapMode?.profile);
|
||||
return (label === 'SYN TERR' && svt3d) || (label === 'PATHWAY' && svtOpts?.pathway) || (label === 'APTSIGNS' && svtOpts?.aptSigns)
|
||||
|| (label === 'HRZN HDG' && svtOpts?.hrznHdg)
|
||||
|| (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)
|
||||
@@ -149,7 +159,9 @@ export default function Bezel({ variant = 'pfd', xp, svt3d, onToggleSvt, inset,
|
||||
<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
|
||||
(s === 'DCLTR' && (mapMode?.dcltr || insetMode?.dcltr)) ? `DCLTR-${mapMode?.dcltr || insetMode?.dcltr}`
|
||||
: (s === 'AIRWAYS' && (mapMode?.airways | 0) >= 2) ? (mapMode.airways === 2 ? 'AIRWY-LO' : 'AIRWY-HI')
|
||||
: s
|
||||
}</span>
|
||||
))}
|
||||
</div>
|
||||
@@ -172,7 +184,7 @@ export default function Bezel({ variant = 'pfd', xp, svt3d, onToggleSvt, inset,
|
||||
<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} mode={knobMode} cmd="direct" onClick={onDirect}>D→</BtnG><BtnG fire={fire} mode={knobMode} cmd="menu">MENU</BtnG>
|
||||
<BtnG fire={fire} mode={knobMode} cmd="direct" onClick={onDirect}>D→</BtnG><BtnG fire={fire} mode={knobMode} cmd="menu" onClick={onMenu}>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>
|
||||
@@ -189,26 +201,30 @@ function BtnG({ fire, cmd, onClick, children }) {
|
||||
}
|
||||
|
||||
// Autopilot mode controller (left bezel of the MFD). Buttons fire real X-Plane
|
||||
// commands; active modes light up from autopilot_state / servos_on.
|
||||
// commands; active modes light from the per-mode *_status datarefs (off/armed/
|
||||
// active) — the same reliable source as the PFD bar and the AutopilotPanel, not
|
||||
// the autopilot_state bitfield (whose bit positions don't match X-Plane).
|
||||
function APController({ xp }) {
|
||||
const st = num(xp.values.apState);
|
||||
const on = (bit) => (st & bit) !== 0;
|
||||
const eng = num(xp.values.apEngaged) > 0;
|
||||
const V = xp.values;
|
||||
const lit = (k) => num(V[k]) > 0;
|
||||
const apMode = num(V.apMode);
|
||||
const eng = num(V.apEngaged) > 0 || apMode >= 2;
|
||||
const fdOn = apMode >= 1 || eng;
|
||||
const B = ({ label, cmd, active }) => (
|
||||
<button className={`ap-key ${active ? 'on' : ''}`} onClick={() => xp.command(cmd)}>{label}</button>
|
||||
);
|
||||
return (
|
||||
<div className="ap-controller">
|
||||
<B label="AP" cmd="apToggle" active={eng} />
|
||||
<B label="FD" cmd="fdToggle" active={on(AP_BITS.fd)} />
|
||||
<B label="HDG" cmd="hdg" active={on(AP_BITS.hdg)} />
|
||||
<B label="ALT" cmd="altHold" active={on(AP_BITS.altHold)} />
|
||||
<B label="NAV" cmd="nav" active={on(AP_BITS.nav)} />
|
||||
<B label="VNV" cmd="vnav" active={on(AP_BITS.vnav)} />
|
||||
<B label="APR" cmd="apr" active={on(AP_BITS.apr)} />
|
||||
<B label="BC" cmd="backCourse" active={on(AP_BITS.bc)} />
|
||||
<B label="VS" cmd="vs" active={on(AP_BITS.vs)} />
|
||||
<B label="FLC" cmd="flc" active={on(AP_BITS.flc)} />
|
||||
<B label="FD" cmd="fdToggle" active={fdOn} />
|
||||
<B label="HDG" cmd="hdg" active={lit('hdgStatus')} />
|
||||
<B label="ALT" cmd="altHold" active={lit('altStatus')} />
|
||||
<B label="NAV" cmd="nav" active={lit('navStatus') || lit('gpssStatus')} />
|
||||
<B label="VNV" cmd="vnav" active={lit('vnavStatus')} />
|
||||
<B label="APR" cmd="apr" active={lit('aprStatus')} />
|
||||
<B label="BC" cmd="backCourse" active={lit('bcStatus')} />
|
||||
<B label="VS" cmd="vs" active={lit('vsStatus')} />
|
||||
<B label="FLC" cmd="flc" active={lit('flcStatus')} />
|
||||
<B label="NOSE UP" cmd="noseUp" />
|
||||
<B label="NOSE DN" cmd="noseDown" />
|
||||
</div>
|
||||
|
||||
+378
-73
@@ -1,17 +1,17 @@
|
||||
import React, { useState } from 'react';
|
||||
import { num, navSearch } from '../api/useXplane.js';
|
||||
import React, { useState, useEffect } from 'react';
|
||||
import { num, navSearch, fmsList } from '../api/useXplane.js';
|
||||
|
||||
// FMS as an X-Plane-style CDU/FMC: a green screen showing the active flight plan
|
||||
// as legs, six line-select keys per side, a scratchpad, and an alphanumeric
|
||||
// keypad. Edits go through the shared flight plan (the same one the PFD/MFD use).
|
||||
// Airliner-style FMS / CDU (Collins/Boeing-like, per the X-Plane FMS manual).
|
||||
// A green screen with six line-select keys (LSK) per side, a scratch pad, page
|
||||
// keys and an alphanumeric keypad. Everything edits the SHARED flight plan (the
|
||||
// same one the PFD/MFD/map use), which the FlyWithLua fms-sync mirrors two-way
|
||||
// into the in-sim FMS — so the app CDU and the aircraft CDU stay synchronized.
|
||||
//
|
||||
// LSK (left, per row):
|
||||
// • scratchpad has an ident → insert that waypoint at the row
|
||||
// • DEL armed → delete the leg at the row
|
||||
// • otherwise → make that leg the active (magenta) leg (Direct-To)
|
||||
// EXEC exports the plan to X-Plane as an .fms file.
|
||||
// Pages: FPLN (origin/dest/flt-no) · LEGS (waypoints) · DEP/ARR (SID/STAR/APPR
|
||||
// via the CIFP parser) · DIR (direct-to) · VNAV (cruise/speed/path-angle) ·
|
||||
// MENU (load/store .fms).
|
||||
|
||||
const R_NM = 3440.065, rad = (d) => d * Math.PI / 180, deg = (r) => r * 180 / Math.PI;
|
||||
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;
|
||||
@@ -22,110 +22,415 @@ function brng(a, b) {
|
||||
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;
|
||||
}
|
||||
// point at distance dNm along bearing brgDeg from a start lat/lon (great-circle).
|
||||
function destPoint(lat, lon, brgDeg, dNm) {
|
||||
const d = dNm / R_NM, t = rad(brgDeg), p1 = rad(lat), l1 = rad(lon);
|
||||
const p2 = Math.asin(Math.sin(p1) * Math.cos(d) + Math.cos(p1) * Math.sin(d) * Math.cos(t));
|
||||
const l2 = l1 + Math.atan2(Math.sin(t) * Math.sin(d) * Math.cos(p1), Math.cos(d) - Math.sin(p1) * Math.sin(p2));
|
||||
return { lat: deg(p2), lon: ((deg(l2) + 540) % 360) - 180 };
|
||||
}
|
||||
|
||||
const ROWS = 5; // legs visible per page
|
||||
const PAGE_KEYS = [['fpln', 'FPLN'], ['legs', 'LEGS'], ['deparr', 'DEP/ARR'], ['dir', 'DIR-INTC'], ['fix', 'FIX'], ['hold', 'HOLD'], ['vnav', 'VNAV'], ['prog', 'PROG'], ['menu', 'MENU']];
|
||||
const LEG_ROWS = 6;
|
||||
const VNAV_PAGES = ['CLB', 'CRZ', 'DES'];
|
||||
// a flight-plan leg with no coordinates is a discontinuity (e.g. a VECTORS or
|
||||
// heading-to-altitude procedure leg) — shown as "DISCONTINUITY" and stitched
|
||||
// over by inserting a waypoint, per the FMS manual (p25-26).
|
||||
const isDisco = (w) => !w || w.type === 'DISCO' || !isFinite(w.lat) || !isFinite(w.lon);
|
||||
|
||||
export default function CDU({ xp }) {
|
||||
const { flightPlan, fp, exportMsg } = xp;
|
||||
export default function CDU({ xp, vnav: vnavCfg, onVnav }) {
|
||||
const { flightPlan, fp, exportMsg, command } = xp;
|
||||
const wps = flightPlan.waypoints || [];
|
||||
const active = Math.max(1, Math.min(wps.length - 1, flightPlan.activeLeg ?? 1));
|
||||
const dest = [...wps].reverse().find((w) => w.type === 'APT')?.id || '';
|
||||
|
||||
const [page, setPage] = useState('fpln');
|
||||
const [scr, setScr] = useState('');
|
||||
const [del, setDel] = useState(false);
|
||||
const [msg, setMsg] = useState('');
|
||||
const [page, setPage] = useState(0);
|
||||
const [legPage, setLegPage] = useState(0);
|
||||
const [fltNo, setFltNo] = useState('');
|
||||
// DEP/ARR
|
||||
const [procs, setProcs] = useState(null);
|
||||
const [cat, setCat] = useState('sids'); // sids | stars | approaches
|
||||
const [procPage, setProcPage] = useState(0);
|
||||
const [selProc, setSelProc] = useState(null); // procedure awaiting a transition pick
|
||||
// VNAV perf (FMS init values; VPA feeds the shared descent profile)
|
||||
const cfg = vnavCfg || { fpa: 3, offsetNm: 0, enabled: true };
|
||||
const [crzAlt, setCrzAlt] = useState('');
|
||||
const [tgtSpd, setTgtSpd] = useState('');
|
||||
const [transAlt, setTransAlt] = useState('18000'); // CLB transition altitude (manual default)
|
||||
const [clbLim, setClbLim] = useState(['250/10000', '']); // up to 2 climb speed/alt restrictions
|
||||
const [desLim, setDesLim] = useState(['250/10000', '']); // up to 2 descent speed/alt restrictions
|
||||
const [vnavPage, setVnavPage] = useState(0); // 0 CLB · 1 CRZ · 2 DES
|
||||
// FIX INFO (manual p17-20): reference navaid + radial/distance → a fix waypoint
|
||||
const [fixRef, setFixRef] = useState(null); // resolved reference navaid {id,lat,lon}
|
||||
const [fixRad, setFixRad] = useState(''); // crossing radial (deg)
|
||||
const [fixDist, setFixDist] = useState(''); // distance along radial (NM)
|
||||
// HOLD (manual: HOLD page): a holding pattern at a fix — inbound course,
|
||||
// turn direction, leg time. Defaults to the active waypoint.
|
||||
const [holdCrs, setHoldCrs] = useState('');
|
||||
const [holdTurn, setHoldTurn] = useState('R');
|
||||
const [holdTime, setHoldTime] = useState('1.0');
|
||||
// STEP (p31): a review cursor stepped through the route with the 6R key.
|
||||
const [stepIdx, setStepIdx] = useState(null);
|
||||
// MOD/ACT: the EXEC light arms while edits are pending, like the real CDU.
|
||||
const [mod, setMod] = useState(false);
|
||||
// saved-plan list (MENU)
|
||||
const [plans, setPlans] = useState(null);
|
||||
|
||||
const pages = Math.max(1, Math.ceil((wps.length + 1) / ROWS));
|
||||
const start = page * ROWS;
|
||||
// every plan edit goes through here so the EXEC light arms (MOD state).
|
||||
const editPlan = (plan) => { fp.set(plan); setMod(true); };
|
||||
|
||||
const type = (ch) => { setMsg(''); setScr((s) => (s + ch).slice(0, 8)); };
|
||||
const flash = (t) => { setMsg(t); setTimeout(() => setMsg(''), 2000); };
|
||||
const type = (ch) => { setMsg(''); setScr((s) => (s + ch).slice(0, 12)); };
|
||||
const clr = () => { if (scr) setScr((s) => s.slice(0, -1)); else { setDel(false); setMsg(''); } };
|
||||
|
||||
// resolve an ident and splice it into the plan at `index`
|
||||
const insertAt = async (ident, index) => {
|
||||
const hits = await navSearch(ident);
|
||||
const hit = hits[0];
|
||||
if (!hit) { setMsg('NOT IN DATABASE'); return; }
|
||||
const next = wps.slice();
|
||||
next.splice(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 });
|
||||
// fetch destination procedures when entering DEP/ARR
|
||||
useEffect(() => {
|
||||
if (page !== 'deparr' || !dest) { return; }
|
||||
let alive = true;
|
||||
fetch(`/api/nav/procs?icao=${dest}`).then((r) => (r.ok ? r.json() : null)).then((d) => { if (alive) setProcs(d); }).catch(() => {});
|
||||
return () => { alive = false; };
|
||||
}, [page, dest]);
|
||||
|
||||
const resolve = async (ident) => (await navSearch(ident))[0] || null;
|
||||
|
||||
const setOrigin = async (ident) => {
|
||||
const h = await resolve(ident); if (!h) return flash('NOT IN DATABASE');
|
||||
editPlan({ name: 'ACTIVE', waypoints: [{ id: h.id, lat: h.lat, lon: h.lon, type: h.type || 'APT', alt: null }], activeLeg: 1 });
|
||||
setScr('');
|
||||
};
|
||||
|
||||
const lsk = (rowIdx) => {
|
||||
const i = start + rowIdx;
|
||||
if (scr) { insertAt(scr, Math.min(i, wps.length)); return; }
|
||||
if (del) { if (i < wps.length) fp.remove(i); setDel(false); return; }
|
||||
if (i >= 1 && i < wps.length) fp.setActive(i);
|
||||
const setDest = async (ident) => {
|
||||
const h = await resolve(ident); if (!h) return flash('NOT IN DATABASE');
|
||||
const next = wps.filter((w) => w.id !== dest || w.type !== 'APT');
|
||||
next.push({ id: h.id, lat: h.lat, lon: h.lon, type: h.type || 'APT', alt: null });
|
||||
editPlan({ name: 'ACTIVE', waypoints: next, activeLeg: flightPlan.activeLeg ?? 1 });
|
||||
setScr('');
|
||||
};
|
||||
const insertAt = async (ident, index) => {
|
||||
const h = await resolve(ident); if (!h) return flash('NOT IN DATABASE');
|
||||
const next = wps.slice();
|
||||
next.splice(index, 0, { id: h.id, lat: h.lat, lon: h.lon, type: h.type || 'WPT', alt: null });
|
||||
editPlan({ name: 'ACTIVE', waypoints: next, activeLeg: flightPlan.activeLeg ?? 1 });
|
||||
setScr('');
|
||||
};
|
||||
const directTo = async (ident) => {
|
||||
const h = await resolve(ident); if (!h) return flash('NOT IN DATABASE');
|
||||
editPlan({ name: 'ACTIVE', waypoints: [
|
||||
{ id: 'PPOS', lat: num(xp.values.lat), lon: num(xp.values.lon), type: 'USR' },
|
||||
{ id: h.id, lat: h.lat, lon: h.lon, type: h.type || 'WPT' },
|
||||
] });
|
||||
command && command('direct');
|
||||
setScr(''); flash(`DIRECT ${h.id}`);
|
||||
};
|
||||
// load a SID/STAR/approach's legs (CIFP) into the plan
|
||||
const loadProc = async (name, trans) => {
|
||||
const t = { sids: 'sid', stars: 'star', approaches: 'approach' }[cat];
|
||||
try {
|
||||
const r = await fetch(`/api/nav/proc?icao=${procs.icao}&type=${t}&name=${encodeURIComponent(name)}&trans=${encodeURIComponent(trans || '')}`);
|
||||
const legs = r.ok ? await r.json() : [];
|
||||
if (!legs.length) return flash('NO LEGS');
|
||||
const tagged = t === 'approach' ? legs.map((l) => (l.seg === 'missed' ? { ...l, missed: true } : { ...l, appr: true })) : legs;
|
||||
const merged = t === 'sid' ? [...tagged, ...wps] : [...wps, ...tagged];
|
||||
editPlan({ name: 'ACTIVE', waypoints: merged, activeLeg: t === 'sid' ? 1 : wps.length || 1 });
|
||||
flash(`${name} LOADED`); setPage('legs');
|
||||
} catch { flash('PROC ERROR'); }
|
||||
};
|
||||
|
||||
const exec = () => { if (wps.length >= 2) fp.export('WEBFPL'); else setMsg('NEED 2 WAYPOINTS'); };
|
||||
// FIX INFO: resolve the reference navaid, then build a fix at radial/distance
|
||||
const setRef = async (ident) => { const h = await resolve(ident); if (!h) return flash('NOT IN DATABASE'); setFixRef({ id: h.id, lat: h.lat, lon: h.lon }); setScr(''); };
|
||||
const insertFix = () => {
|
||||
if (!fixRef) return flash('SET REF NAVAID');
|
||||
const radNum = parseFloat(fixRad); if (!isFinite(radNum)) return flash('SET RADIAL');
|
||||
const d = parseFloat(fixDist) || 0;
|
||||
const pt = d > 0 ? destPoint(fixRef.lat, fixRef.lon, radNum, d) : { lat: fixRef.lat, lon: fixRef.lon };
|
||||
const id = `${fixRef.id}${String(Math.round(radNum)).padStart(3, '0')}`.slice(0, 5);
|
||||
const next = wps.slice(); next.splice(Math.max(1, wps.length - 1), 0, { id, lat: pt.lat, lon: pt.lon, type: 'FIX' });
|
||||
editPlan({ name: 'ACTIVE', waypoints: next, activeLeg: flightPlan.activeLeg ?? 1 });
|
||||
flash(`FIX ${id} INSERTED`);
|
||||
};
|
||||
|
||||
// build the visible rows
|
||||
const rows = [];
|
||||
for (let r = 0; r < ROWS; r++) {
|
||||
const i = start + r;
|
||||
if (i < wps.length) {
|
||||
const w = wps[i], prev = wps[i - 1];
|
||||
const d = prev ? distNm(prev, w) : 0;
|
||||
const dtk = prev ? Math.round(brng(prev, w)) : null;
|
||||
rows.push({ i, id: w.id, type: w.type, d, dtk, orig: i === 0, act: i === active });
|
||||
} else if (i === wps.length) {
|
||||
rows.push({ i, empty: true });
|
||||
} else {
|
||||
rows.push({ i, blank: true });
|
||||
const exec = () => { if (wps.length >= 2) { fp.export(fltNo || 'WEBFPL'); setMod(false); flash('ACTIVATED'); } else flash('NEED 2 WAYPOINTS'); };
|
||||
const openLoad = async () => setPlans(await fmsList());
|
||||
|
||||
// ---- per-page line-select-key handling ----------------------------------
|
||||
const onLsk = (side, r) => {
|
||||
if (page === 'fpln') {
|
||||
if (side === 'L' && r === 0 && scr) return setOrigin(scr);
|
||||
if (side === 'R' && r === 0 && scr) return setDest(scr);
|
||||
if (side === 'R' && r === 1) { if (scr) { setFltNo(scr); setScr(''); } return; }
|
||||
if (side === 'L' && r === 5) return setPage('menu');
|
||||
if (side === 'R' && r === 5) return setPage('vnav');
|
||||
return;
|
||||
}
|
||||
if (page === 'legs') {
|
||||
const i = legPage * LEG_ROWS + r;
|
||||
if (side === 'L') {
|
||||
if (scr) return insertAt(scr, Math.min(i, wps.length)); // insert / stitch a discontinuity
|
||||
if (del) { if (i < wps.length) { fp.remove(i); setMod(true); } setDel(false); return; }
|
||||
if (i < wps.length && isDisco(wps[i])) { fp.remove(i); setMod(true); return; } // clear discontinuity
|
||||
if (i >= 1 && i < wps.length) { fp.setActive(i); setMod(true); }
|
||||
}
|
||||
// STEP (6R): advance a review cursor through the route, auto-paging (p31)
|
||||
if (side === 'R' && r === 5 && wps.length) {
|
||||
setStepIdx((s) => { const n = ((s == null ? active - 1 : s) + 1) % wps.length; setLegPage(Math.floor(n / LEG_ROWS)); return n; });
|
||||
}
|
||||
return;
|
||||
}
|
||||
if (page === 'deparr') {
|
||||
if (side === 'R' && r < 3) { setCat(['sids', 'stars', 'approaches'][r]); setProcPage(0); setSelProc(null); return; }
|
||||
if (side === 'L') {
|
||||
// step 1: a procedure is selected → if it has transitions, pick one; else load
|
||||
if (selProc) {
|
||||
if (r === 0) return loadProc(selProc.name, ''); // NO TRANS / direct
|
||||
const tr = (selProc.transitions || [])[r - 1];
|
||||
if (tr) return loadProc(selProc.name, tr);
|
||||
return;
|
||||
}
|
||||
const list = (procs && procs[cat]) || [];
|
||||
const p = list[procPage * 5 + r];
|
||||
if (p) { if (p.transitions && p.transitions.length) return setSelProc(p); return loadProc(p.name, ''); }
|
||||
}
|
||||
return;
|
||||
}
|
||||
if (page === 'dir') { if (scr) return directTo(scr); return; }
|
||||
if (page === 'fix') {
|
||||
if (side === 'L' && r === 0 && scr) return setRef(scr);
|
||||
if (side === 'L' && r === 1 && scr) { setFixRad(scr); setScr(''); return; }
|
||||
if (side === 'L' && r === 2 && scr) { setFixDist(scr); setScr(''); return; }
|
||||
if (side === 'R' && r === 0) return insertFix();
|
||||
return;
|
||||
}
|
||||
if (page === 'hold') {
|
||||
if (side === 'L' && r === 1 && scr) { setHoldCrs(scr); setScr(''); return; }
|
||||
if (side === 'R' && r === 1) { setHoldTurn((t) => (t === 'R' ? 'L' : 'R')); return; }
|
||||
if (side === 'L' && r === 2 && scr) { setHoldTime(scr); setScr(''); return; }
|
||||
return;
|
||||
}
|
||||
if (page === 'vnav') {
|
||||
if (vnavPage === 0) { // CLB: trans alt (1L), speed/alt limits (2L, 3L)
|
||||
if (side === 'L' && r === 0 && scr) { setTransAlt(scr); setScr(''); return; }
|
||||
if (side === 'L' && r === 1 && scr) { setClbLim((a) => [scr, a[1]]); setScr(''); return; }
|
||||
if (side === 'L' && r === 2 && scr) { setClbLim((a) => [a[0], scr]); setScr(''); return; }
|
||||
if (side === 'L' && r === 2 && del) { setClbLim((a) => [a[0], '']); setDel(false); return; }
|
||||
} else if (vnavPage === 1) { // CRZ: cruise alt (1L), target speed (1R)
|
||||
if (side === 'L' && r === 0 && scr) { setCrzAlt(scr); setScr(''); return; }
|
||||
if (side === 'R' && r === 0 && scr) { setTgtSpd(scr.replace('/', '')); setScr(''); return; }
|
||||
} else { // DES: target speed (1L), VPA (1R), speed/alt limits (2L, 3L)
|
||||
if (side === 'R' && r === 0 && scr && onVnav) { const v = parseFloat(scr); if (v >= 2 && v <= 6) onVnav((c) => ({ ...c, fpa: v })); setScr(''); return; }
|
||||
if (side === 'L' && r === 0 && scr) { setTgtSpd(scr.replace('/', '')); setScr(''); return; }
|
||||
if (side === 'L' && r === 1 && scr) { setDesLim((a) => [scr, a[1]]); setScr(''); return; }
|
||||
if (side === 'L' && r === 2 && scr) { setDesLim((a) => [a[0], scr]); setScr(''); return; }
|
||||
if (side === 'L' && r === 2 && del) { setDesLim((a) => [a[0], '']); setDel(false); return; }
|
||||
}
|
||||
return;
|
||||
}
|
||||
if (page === 'prog') return;
|
||||
if (page === 'menu') {
|
||||
if (side === 'L' && r === 0) return openLoad();
|
||||
if (side === 'L' && r === 1) return exec();
|
||||
if (plans && side === 'L') { const n = plans[(r - 2)]; if (n) { fp.load(n); setPlans(null); flash(`${n} LOADED`); } }
|
||||
}
|
||||
};
|
||||
|
||||
// ---- page body (12 lines, mapped to LSK 1L..6L / 1R..6R) ----------------
|
||||
const A = 'ABCDEFGHIJKLMNOPQRSTUVWXYZ'.split('');
|
||||
const KEYS = [A.slice(0, 7), A.slice(7, 14), A.slice(14, 21), A.slice(21, 26).concat(['/', ' ']), ['1', '2', '3', '4', '5'], ['6', '7', '8', '9', '0']];
|
||||
|
||||
const legPages = Math.max(1, Math.ceil((wps.length + 1) / LEG_ROWS));
|
||||
const procList = (procs && procs[cat]) || [];
|
||||
const procPages = Math.max(1, Math.ceil(procList.length / 5));
|
||||
|
||||
let title = 'ACT FPLN', pageNo = '1/1', body = null;
|
||||
if (page === 'fpln') {
|
||||
body = (
|
||||
<div className="cdu-fpln">
|
||||
<div className="cdu-fl"><label>ORIGIN</label><b>{wps[0]?.id || '____'}</b></div>
|
||||
<div className="cdu-fl r"><label>DEST</label><b>{dest || '____'}</b></div>
|
||||
<div className="cdu-fl r"><label>FLT NO</label><b>{fltNo || '--------'}</b></div>
|
||||
<div className="cdu-fl bot"><span className="cdu-link"><ROUTE MENU</span><span className="cdu-link r">VNAV></span></div>
|
||||
</div>
|
||||
);
|
||||
} else if (page === 'legs') {
|
||||
title = del ? 'DELETE' : 'ACT LEGS'; pageNo = `${legPage + 1}/${legPages}`;
|
||||
const rows = [];
|
||||
for (let r = 0; r < LEG_ROWS; r++) {
|
||||
const i = legPage * LEG_ROWS + r;
|
||||
if (i < wps.length) {
|
||||
const w = wps[i], prev = wps[i - 1];
|
||||
if (isDisco(w)) { rows.push({ disco: true }); continue; }
|
||||
const linkable = prev && !isDisco(prev);
|
||||
rows.push({ id: w.id, type: w.type, dtk: linkable ? Math.round(brng(prev, w)) : null, d: linkable ? distNm(prev, w) : 0, orig: i === 0, act: i === active, missed: w.missed, step: i === stepIdx });
|
||||
}
|
||||
else if (i === wps.length) rows.push({ add: true });
|
||||
else rows.push({ blank: true });
|
||||
}
|
||||
body = (<><div className="cdu-cols2">{rows.map((row, r) => (
|
||||
<div className={`cdu-row ${row.act ? 'act' : ''} ${row.missed ? 'dim' : ''} ${row.disco ? 'disco' : ''} ${row.step ? 'step' : ''}`} key={r}>
|
||||
{row.blank ? <span className="cdu-empty">·</span> : row.add ? <span className="cdu-add"><----- ENTER WPT</span>
|
||||
: row.disco ? <span className="cdu-add">─── DISCONTINUITY ───</span>
|
||||
: (<><span className="cdu-wpt">{row.id}<i>{row.type}</i></span><span className="cdu-dtk">{row.dtk == null ? '---' : `${String(row.dtk).padStart(3, '0')}°`}</span><span className="cdu-dist">{row.orig ? 'ORIG' : row.d.toFixed(1)}</span></>)}
|
||||
</div>))}</div><div className="cdu-note small">STEP through route: 6R{stepIdx != null ? ` · viewing ${wps[stepIdx]?.id || ''}` : ''}</div></>);
|
||||
} else if (page === 'deparr') {
|
||||
const kind = cat === 'sids' ? 'DEPART' : cat === 'stars' ? 'ARRIVAL' : 'APPROACH';
|
||||
if (selProc) {
|
||||
title = `${selProc.name} TRANS`; pageNo = `1/1`;
|
||||
body = (
|
||||
<div className="cdu-deparr">
|
||||
<div className="cdu-prow act"><span><NO TRANS</span><i>direct</i></div>
|
||||
{(selProc.transitions || []).slice(0, 4).map((t) => <div className="cdu-prow" key={t}><span><{t}</span></div>)}
|
||||
<div className="cdu-note small">Transition wählen (LSK) · oder NO TRANS (1L)</div>
|
||||
</div>
|
||||
);
|
||||
} else {
|
||||
title = `${dest || '----'} ${kind}`; pageNo = `${procPage + 1}/${procPages}`;
|
||||
const shown = procList.slice(procPage * 5, procPage * 5 + 5);
|
||||
body = (
|
||||
<div className="cdu-deparr">
|
||||
<div className="cdu-tabs"><span className={cat === 'sids' ? 'on' : ''}>SID></span><span className={cat === 'stars' ? 'on' : ''}>STAR></span><span className={cat === 'approaches' ? 'on' : ''}>APPR></span></div>
|
||||
{!procs && <div className="cdu-note">{dest ? 'loading…' : 'set DEST on FPLN'}</div>}
|
||||
{procs && shown.length === 0 && <div className="cdu-note">none</div>}
|
||||
{shown.map((p, i) => <div className="cdu-prow" key={p.name + i}><span><{p.name}</span>{p.transitions?.length ? <i>{p.transitions.length} TR></i> : null}</div>)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
} else if (page === 'prog') {
|
||||
title = 'PROGRESS';
|
||||
const here = { lat: num(xp.values.lat), lon: num(xp.values.lon) };
|
||||
const gs = Math.max(1, Math.round(num(xp.values.groundspeed) * 1.94384));
|
||||
const actW = wps[active], destW = wps[wps.length - 1];
|
||||
const dA = actW && here.lat ? distNm(here, actW) : 0;
|
||||
const dD = destW && here.lat ? distNm(here, destW) : 0;
|
||||
const ete = (d) => (gs > 5 ? `${Math.floor(d / gs * 60)}:${String(Math.round((d / gs * 60 % 1) * 60)).padStart(2, '0')}` : '--:--');
|
||||
body = (
|
||||
<div className="cdu-vnav">
|
||||
<div className="cdu-fl"><label>TO</label><b>{actW?.id || '----'}</b></div>
|
||||
<div className="cdu-fl r"><label>DTG</label><b>{dA.toFixed(1)} NM</b></div>
|
||||
<div className="cdu-fl r"><label>ETE</label><b>{ete(dA)}</b></div>
|
||||
<div className="cdu-fl"><label>DEST</label><b>{destW?.id || '----'}</b></div>
|
||||
<div className="cdu-fl r"><label>DEST DTG</label><b>{dD.toFixed(0)} NM</b></div>
|
||||
<div className="cdu-fl r"><label>DEST ETE</label><b>{ete(dD)}</b></div>
|
||||
<div className="cdu-note small">GS {gs} KT · TAS {Math.round(num(xp.values.tas))} · SAT {Math.round(num(xp.values.oat))}°C</div>
|
||||
</div>
|
||||
);
|
||||
} else if (page === 'dir') {
|
||||
title = 'DIRECT TO';
|
||||
body = (
|
||||
<div className="cdu-dir">
|
||||
<div className="cdu-note">Ident eingeben, dann LSK 1L</div>
|
||||
<div className="cdu-fl"><label>DIRECT TO</label><b>{scr || '____'}</b></div>
|
||||
{wps.length >= 2 && <div className="cdu-note small">aktiv: {wps[active]?.id}</div>}
|
||||
</div>
|
||||
);
|
||||
} else if (page === 'fix') {
|
||||
title = 'FIX INFO';
|
||||
body = (
|
||||
<div className="cdu-vnav">
|
||||
<div className="cdu-fl"><label>REF</label><b>{fixRef?.id || '____'}</b></div>
|
||||
<div className="cdu-fl"><label>RAD CROSS</label><b>{fixRad ? `${fixRad}°` : '---°'}</b></div>
|
||||
<div className="cdu-fl"><label>DIST</label><b>{fixDist ? `${fixDist} NM` : '--- NM'}</b></div>
|
||||
<div className="cdu-fl r"><label>INSERT</label><b>FIX></b></div>
|
||||
<div className="cdu-note small">REF:1L · RADIAL:2L · DIST:3L · INSERT:1R</div>
|
||||
</div>
|
||||
);
|
||||
} else if (page === 'hold') {
|
||||
const hf = wps[active] || wps[wps.length - 1];
|
||||
title = 'HOLD';
|
||||
body = (
|
||||
<div className="cdu-vnav">
|
||||
<div className="cdu-fl"><label>HOLD AT</label><b>{hf?.id || '----'}</b></div>
|
||||
<div className="cdu-fl"><label>INBD CRS</label><b>{holdCrs ? `${holdCrs}°` : '---°'}</b></div>
|
||||
<div className="cdu-fl r"><label>TURN</label><b>{holdTurn === 'R' ? 'RIGHT' : 'LEFT'}</b></div>
|
||||
<div className="cdu-fl"><label>LEG TIME</label><b>{holdTime} MIN</b></div>
|
||||
<div className="cdu-note small">INBD CRS:2L · TURN:2R · LEG TIME:3L</div>
|
||||
</div>
|
||||
);
|
||||
} else if (page === 'vnav') {
|
||||
title = `ACT VNAV ${VNAV_PAGES[vnavPage]}`; pageNo = `${vnavPage + 1}/3`;
|
||||
if (vnavPage === 0) { // CLB (manual p21-22)
|
||||
body = (
|
||||
<div className="cdu-vnav">
|
||||
<div className="cdu-fl"><label>TRANS ALT</label><b>{transAlt || '18000'}</b></div>
|
||||
<div className="cdu-fl r"><label>TGT SPD</label><b>{tgtSpd || '290/.74'}</b></div>
|
||||
<div className="cdu-fl"><label>SPD/ALT LIM 1</label><b>{clbLim[0] || '-----'}</b></div>
|
||||
<div className="cdu-fl"><label>SPD/ALT LIM 2</label><b>{clbLim[1] || '-----'}</b></div>
|
||||
<div className="cdu-note small">TRANS ALT:1L · SPD/ALT LIM:2L/3L (DEL clears) · NEXT→CRZ</div>
|
||||
</div>
|
||||
);
|
||||
} else if (vnavPage === 1) { // CRZ (manual p23)
|
||||
body = (
|
||||
<div className="cdu-vnav">
|
||||
<div className="cdu-fl"><label>CRZ ALT</label><b>{crzAlt ? `FL${crzAlt}` : '-----'}</b></div>
|
||||
<div className="cdu-fl r"><label>TGT SPD</label><b>{tgtSpd || '.80'}</b></div>
|
||||
<div className="cdu-note small">CRZ ALT: 1L (e.g. 280=FL280) · TGT SPD: 1R</div>
|
||||
</div>
|
||||
);
|
||||
} else { // DES (manual p23-24)
|
||||
body = (
|
||||
<div className="cdu-vnav">
|
||||
<div className="cdu-fl"><label>TGT SPD</label><b>{tgtSpd || '/200'}</b></div>
|
||||
<div className="cdu-fl r"><label>VPA</label><b>{(cfg.fpa || 3).toFixed(1)}°</b></div>
|
||||
<div className="cdu-fl"><label>SPD/ALT LIM 1</label><b>{desLim[0] || '-----'}</b></div>
|
||||
<div className="cdu-fl"><label>SPD/ALT LIM 2</label><b>{desLim[1] || '-----'}</b></div>
|
||||
<div className="cdu-note small">TGT SPD:1L · VPA:1R(2.0-6.0°) · SPD/ALT LIM:2L/3L</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
} else if (page === 'menu') {
|
||||
title = plans ? 'CO ROUTE LIST' : 'ROUTE MENU';
|
||||
body = plans ? (
|
||||
<div className="cdu-menu">{plans.length === 0 ? <div className="cdu-note">keine .fms</div> : plans.slice(0, 5).map((n) => <div className="cdu-prow" key={n}><span><{n}</span><i>.fms</i></div>)}</div>
|
||||
) : (
|
||||
<div className="cdu-menu">
|
||||
<div className="cdu-prow"><span><LOAD (CO ROUTE)</span></div>
|
||||
<div className="cdu-prow"><span><STORE / EXPORT</span></div>
|
||||
<div className="cdu-note small">STORE schreibt {fltNo || 'WEBFPL'}.fms</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
const A = 'ABCDEFGHIJKLMNOPQRSTUVWXYZ'.split('');
|
||||
const KEYS = [A.slice(0, 7), A.slice(7, 14), A.slice(14, 21), A.slice(21, 26).concat([' ']), ['1', '2', '3', '4', '5'], ['6', '7', '8', '9', '0']];
|
||||
// MOD/ACT: the title and EXEC light reflect whether edits are pending.
|
||||
if (title.startsWith('ACT ')) title = (mod ? 'MOD ' : 'ACT ') + title.slice(4);
|
||||
|
||||
const Lsk = ({ side, r }) => <button className={`cdu-lsk ${side}`} onClick={() => lsk(r)} aria-label={`LSK ${r + 1}${side}`} />;
|
||||
const Lsk = ({ side, r }) => <button className={`cdu-lsk ${side}`} onClick={() => onLsk(side, r)} aria-label={`LSK${r + 1}${side}`} />;
|
||||
|
||||
return (
|
||||
<div className="cdu">
|
||||
<div className="cdu-unit">
|
||||
<div className="cdu-screenwrap">
|
||||
<div className="cdu-lsks left">{[0, 1, 2, 3, 4].map((r) => <Lsk key={r} side="L" r={r} />)}</div>
|
||||
<div className="cdu-lsks left">{[0, 1, 2, 3, 4, 5].map((r) => <Lsk key={r} side="L" r={r} />)}</div>
|
||||
<div className="cdu-screen">
|
||||
<div className="cdu-hdr">
|
||||
<span>{del ? 'DELETE' : 'ACT FPL'}</span>
|
||||
<span>{page + 1}/{pages}</span>
|
||||
</div>
|
||||
<div className="cdu-cols"><span>WPT</span><span>DTK</span><span>DIST</span></div>
|
||||
{rows.map((row) => (
|
||||
<div className={`cdu-row ${row.act ? 'act' : ''}`} key={row.i}>
|
||||
{row.blank ? <span className="cdu-empty">·</span>
|
||||
: row.empty ? <span className="cdu-add"><------ ENTER WPT</span>
|
||||
: (<>
|
||||
<span className="cdu-wpt">{row.id}<i>{row.type}</i></span>
|
||||
<span className="cdu-dtk">{row.dtk == null ? '---' : `${String(row.dtk).padStart(3, '0')}°`}</span>
|
||||
<span className="cdu-dist">{row.orig ? 'ORIG' : `${row.d.toFixed(1)}`}</span>
|
||||
</>)}
|
||||
</div>
|
||||
))}
|
||||
<div className="cdu-hdr"><span>{title}</span><span>{pageNo}</span></div>
|
||||
<div className="cdu-body">{body}</div>
|
||||
<div className="cdu-scratch">
|
||||
<span className="cdu-sp">{scr || (del ? 'DELETE—SEL LEG' : '')}</span>
|
||||
{msg && <span className="cdu-msg">{msg}</span>}
|
||||
{exportMsg && !msg && <span className="cdu-msg ok">{exportMsg.ok ? 'EXPORTED ✓' : exportMsg.error}</span>}
|
||||
</div>
|
||||
</div>
|
||||
<div className="cdu-lsks right">{[0, 1, 2, 3, 4].map((r) => <Lsk key={r} side="R" r={r} />)}</div>
|
||||
<div className="cdu-lsks right">{[0, 1, 2, 3, 4, 5].map((r) => <Lsk key={r} side="R" r={r} />)}</div>
|
||||
</div>
|
||||
|
||||
{/* page keys */}
|
||||
<div className="cdu-pages">
|
||||
{PAGE_KEYS.map(([id, lbl]) => (
|
||||
<button key={id} className={`cdu-k pg ${page === id ? 'on' : ''}`} onClick={() => { setPage(id); setScr(''); setDel(false); setPlans(null); setSelProc(null); }}>{lbl}</button>
|
||||
))}
|
||||
</div>
|
||||
|
||||
<div className="cdu-fn">
|
||||
<button className="cdu-k fn" onClick={() => setPage((p) => Math.max(0, p - 1))}>PREV</button>
|
||||
<button className="cdu-k fn" onClick={() => setPage((p) => Math.min(pages - 1, p + 1))}>NEXT</button>
|
||||
<button className="cdu-k fn" onClick={() => { if (page === 'legs') setLegPage((p) => Math.max(0, p - 1)); else if (page === 'deparr') setProcPage((p) => Math.max(0, p - 1)); else if (page === 'vnav') setVnavPage((p) => Math.max(0, p - 1)); }}>PREV</button>
|
||||
<button className="cdu-k fn" onClick={() => { if (page === 'legs') setLegPage((p) => Math.min(legPages - 1, p + 1)); else if (page === 'deparr') setProcPage((p) => Math.min(procPages - 1, p + 1)); else if (page === 'vnav') setVnavPage((p) => Math.min(2, p + 1)); }}>NEXT</button>
|
||||
<button className={`cdu-k fn ${del ? 'arm' : ''}`} onClick={() => { setDel((d) => !d); setScr(''); }}>DEL</button>
|
||||
<button className="cdu-k fn" onClick={clr}>CLR</button>
|
||||
<button className="cdu-k fn exec" onClick={exec}>EXEC</button>
|
||||
<button className={`cdu-k fn exec ${mod ? 'arm' : ''}`} onClick={exec}>EXEC</button>
|
||||
</div>
|
||||
|
||||
<div className="cdu-pad">
|
||||
{KEYS.map((rowK, ri) => (
|
||||
<div className="cdu-padrow" key={ri}>
|
||||
{rowK.map((k) => (
|
||||
<button key={k} className="cdu-k" onClick={() => type(k === ' ' ? ' ' : k)}>{k === ' ' ? 'SP' : k}</button>
|
||||
))}
|
||||
{rowK.map((k) => <button key={k} className="cdu-k" onClick={() => type(k)}>{k === ' ' ? 'SP' : k}</button>)}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
|
||||
@@ -17,11 +17,14 @@ function distBrg(la1, lo1, la2, lo2) {
|
||||
return { dist, brg };
|
||||
}
|
||||
|
||||
export default function DirectTo({ xp, onClose }) {
|
||||
export default function DirectTo({ xp, onClose, vnav, onVnav }) {
|
||||
const { values, fp, command } = xp;
|
||||
const cfg = vnav || { enabled: true, fpa: 3, offsetNm: 0 };
|
||||
const [entry, setEntry] = useState('');
|
||||
const [hits, setHits] = useState([]);
|
||||
const [sel, setSel] = useState(null); // chosen { id, lat, lon, type }
|
||||
const [altFt, setAltFt] = useState(''); // optional VNAV target altitude
|
||||
const [agl, setAgl] = useState(false); // MSL vs AGL reference (for airports)
|
||||
const inputRef = useRef(null);
|
||||
|
||||
useEffect(() => { inputRef.current?.focus(); }, []);
|
||||
@@ -40,11 +43,17 @@ export default function DirectTo({ xp, onClose }) {
|
||||
|
||||
const activate = () => {
|
||||
if (!sel) return;
|
||||
// Optional VNAV descent: a target altitude makes the Direct-To waypoint a
|
||||
// designated VNAV fix, so the CURRENT VNV PROFILE + PFD chevrons compute the
|
||||
// descent (FPA/offset from the shared VNAV config). AGL adds field elevation.
|
||||
const a = parseInt(altFt, 10);
|
||||
const tgtAlt = isFinite(a) && a > 0 ? (agl ? a + (num(sel.elev) || 0) : a) : null;
|
||||
fp.set({ name: 'ACTIVE', waypoints: [
|
||||
{ id: 'PPOS', lat, lon, type: 'USR' },
|
||||
{ id: sel.id, lat: sel.lat, lon: sel.lon, type: sel.type || 'WPT' },
|
||||
{ id: sel.id, lat: sel.lat, lon: sel.lon, type: sel.type || 'WPT', ...(tgtAlt ? { alt: tgtAlt, dsgn: true } : {}) },
|
||||
] });
|
||||
command('direct'); // mirror to the in-sim G1000
|
||||
if (tgtAlt && onVnav) onVnav((c) => ({ ...c, enabled: true })); // arm VNAV for the descent
|
||||
onClose();
|
||||
};
|
||||
|
||||
@@ -74,11 +83,23 @@ export default function DirectTo({ xp, onClose }) {
|
||||
</div>
|
||||
)}
|
||||
<div className="dto-grid">
|
||||
<b>ALT</b><span>_____FT</span><b>OFFSET</b><span>+0NM</span>
|
||||
<b>ALT</b>
|
||||
<span className="dto-altedit">
|
||||
<input className="dto-alt" value={altFt} inputMode="numeric"
|
||||
onChange={(e) => setAltFt(e.target.value.replace(/[^0-9]/g, '').slice(0, 5))}
|
||||
placeholder="_____" />FT
|
||||
<button className="dto-unit" onClick={() => setAgl((g) => !g)}>{agl ? 'AGL' : 'MSL'}</button>
|
||||
</span>
|
||||
<b>OFFSET</b>
|
||||
<span className="dto-off">
|
||||
<button onClick={() => onVnav && onVnav((c) => ({ ...c, offsetNm: Math.max(0, (c.offsetNm || 0) - 1) }))}>−</button>
|
||||
{cfg.offsetNm || 0}NM
|
||||
<button onClick={() => onVnav && onVnav((c) => ({ ...c, offsetNm: Math.min(20, (c.offsetNm || 0) + 1) }))}>+</button>
|
||||
</span>
|
||||
<b>BRG</b><span>{preview ? `${String(Math.round(preview.brg)).padStart(3, '0')}°` : '___°'}</span>
|
||||
<b>FPA</b><span>{(cfg.fpa || 3).toFixed(1)}°</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>
|
||||
|
||||
@@ -18,7 +18,8 @@ function brng(a, b) {
|
||||
}
|
||||
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 }) {
|
||||
export default function FplPage({ xp, full = false, onClose, vnav: vnavCfg, onVnav }) {
|
||||
const cfg = vnavCfg || { enabled: true, fpa: 3, offsetNm: 0 };
|
||||
const { flightPlan, fp, values, exportMsg } = xp;
|
||||
const wps = flightPlan.waypoints || [];
|
||||
const active = Math.max(1, Math.min(wps.length - 1, flightPlan.activeLeg ?? 1));
|
||||
@@ -69,18 +70,24 @@ export default function FplPage({ xp, full = false, onClose }) {
|
||||
// from the path, time-to-top-of-descent.
|
||||
const alt = num(values.altitude);
|
||||
let vnav = null;
|
||||
if (gs > 40) {
|
||||
if (cfg.enabled && gs > 40) {
|
||||
const tan = Math.tan((cfg.fpa * Math.PI) / 180);
|
||||
const off = Math.max(0, cfg.offsetNm || 0);
|
||||
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 d = Math.max(0, c - off); // distance to the level-off point (offset before wpt)
|
||||
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 };
|
||||
const vsReq = d > 0 ? (t - alt) / (d / gs * 60) : 0;
|
||||
const vDev = alt - (t + d * 6076.12 * tan);
|
||||
const todNm = d - (alt - t) / (6076.12 * tan);
|
||||
// Before TOD: time until the descent path is intercepted. After TOD (already
|
||||
// descending): time to Bottom of Descent = reaching the level-off point.
|
||||
const todSec = todNm > 0 ? (todNm / gs) * 3600 : 0;
|
||||
const bodSec = todNm > 0 ? 0 : (d / gs) * 3600;
|
||||
vnav = { wptId: wps[i].id, tgtAlt: t, vsTgt, vsReq, vDev, fpa: cfg.fpa, todSec, bodSec, off };
|
||||
break;
|
||||
}
|
||||
}
|
||||
@@ -124,10 +131,25 @@ export default function FplPage({ xp, full = false, onClose }) {
|
||||
<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>VS REQ</b><span>{Math.round(vnav.vsReq)}<u>FPM</u></span>
|
||||
{vnav.todSec > 0
|
||||
? <><b>TIME TO TOD</b><span>{fmtSec(vnav.todSec)}</span></>
|
||||
: <><b>TIME TO BOD</b><span>{fmtSec(vnav.bodSec)}</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 className="fpl-vnav-none">{cfg.enabled ? '— no active VNAV profile —' : '— VNAV cancelled —'}</div>}
|
||||
{onVnav && (
|
||||
<div className="fpl-vnav-keys">
|
||||
<button className={cfg.enabled ? 'on' : ''} onClick={() => onVnav((c) => ({ ...c, enabled: !c.enabled }))}>{cfg.enabled ? 'CNCL VNV' : 'ENBL VNV'}</button>
|
||||
<button onClick={() => onVnav((c) => ({ ...c, fpa: +Math.max(2, c.fpa - 0.5).toFixed(1) }))}>FPA−</button>
|
||||
<span className="vk-val">{cfg.fpa.toFixed(1)}°</span>
|
||||
<button onClick={() => onVnav((c) => ({ ...c, fpa: +Math.min(6, c.fpa + 0.5).toFixed(1) }))}>FPA+</button>
|
||||
<button onClick={() => onVnav((c) => ({ ...c, offsetNm: Math.max(0, (c.offsetNm || 0) - 1) }))}>ATK−</button>
|
||||
<span className="vk-val">{cfg.offsetNm || 0}<u>NM</u></span>
|
||||
<button onClick={() => onVnav((c) => ({ ...c, offsetNm: Math.min(20, (c.offsetNm || 0) + 1) }))}>ATK+</button>
|
||||
<button className="vnvd" onClick={() => onVnav((c) => ({ ...c, enabled: true }))}>VNV-D→</button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
<div className="fpl-entry">
|
||||
|
||||
@@ -38,7 +38,7 @@ const fmtEte = (s) => {
|
||||
// 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.
|
||||
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 }) {
|
||||
export default function MFD({ values: V, flightPlan, fp, mapMode, page = 'map', onCycle, xp, vnav, onVnav }) {
|
||||
const [rangeNm, setRangeNm] = useState(8);
|
||||
const idx = Math.max(0, MFD_PAGES.findIndex((p) => p.id === page));
|
||||
return (
|
||||
@@ -54,8 +54,9 @@ export default function MFD({ values: V, flightPlan, fp, mapMode, page = 'map',
|
||||
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 === 'map' && mapMode?.profile && <VertProfile V={V} fp={flightPlan} />}
|
||||
{page === 'nrst' && <Nearest xp={xp} full />}
|
||||
{page === 'fpl' && xp && <FplPage xp={xp} full vnav={vnav} onVnav={onVnav} />}
|
||||
{/* 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)">
|
||||
@@ -123,6 +124,59 @@ function MfdTopBar({ V, fp }) {
|
||||
);
|
||||
}
|
||||
|
||||
/* ---------------- vertical profile (PROFILE softkey) ---------------- */
|
||||
// Altitude-vs-distance view along the active flight plan: the aircraft at the
|
||||
// left, upcoming waypoints with their target altitudes, and the planned descent
|
||||
// path between them. Pure geometry from the plan + current altitude.
|
||||
function VertProfile({ V, fp }) {
|
||||
const R = 3440.065, rad = (d) => (d * Math.PI) / 180;
|
||||
const dist = (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 * Math.asin(Math.min(1, Math.sqrt(s)));
|
||||
};
|
||||
const wps = fp?.waypoints || [];
|
||||
const ai = Math.max(1, Math.min(wps.length - 1, fp?.activeLeg ?? 1));
|
||||
const alt = num(V.altitude);
|
||||
const pts = []; let cum = 0, prev = { lat: num(V.lat), lon: num(V.lon) };
|
||||
for (let i = ai; i < wps.length; i++) { cum += dist(prev, wps[i]); prev = wps[i]; pts.push({ d: cum, alt: num(wps[i].alt) || null, id: wps[i].id }); }
|
||||
const maxD = Math.max(10, cum);
|
||||
const altMax = Math.max(alt, ...pts.map((p) => p.alt || 0), 3000) * 1.15;
|
||||
const W = 1000, H = 200, padL = 46, padR = 12, padT = 12, padB = 22;
|
||||
const x = (d) => padL + (d / maxD) * (W - padL - padR);
|
||||
const y = (a) => (H - padB) - (a / altMax) * (H - padT - padB);
|
||||
const altPts = pts.filter((p) => p.alt != null);
|
||||
const path = [`${x(0)},${y(alt)}`, ...altPts.map((p) => `${x(p.d)},${y(p.alt)}`)].join(' ');
|
||||
const grid = [0, altMax / 2, altMax].map((a) => Math.round(a / 500) * 500);
|
||||
return (
|
||||
<div className="vprof">
|
||||
<svg viewBox={`0 0 ${W} ${H}`} preserveAspectRatio="none">
|
||||
<rect x="0" y="0" width={W} height={H} fill="#05080b" />
|
||||
{grid.map((a) => (
|
||||
<g key={a}>
|
||||
<line x1={padL} y1={y(a)} x2={W - padR} y2={y(a)} stroke="#1b2730" strokeWidth="1" />
|
||||
<text x={padL - 5} y={y(a) + 4} fill="#6f808d" fontSize="11" textAnchor="end" fontFamily="monospace">{Math.round(a / 100) * 100}</text>
|
||||
</g>
|
||||
))}
|
||||
{/* planned vertical path through waypoint target altitudes */}
|
||||
{altPts.length > 0 && <polyline points={path} fill="none" stroke="#ff20ff" strokeWidth="2.5" />}
|
||||
{/* waypoints */}
|
||||
{pts.map((p, i) => (
|
||||
<g key={p.id + i}>
|
||||
<line x1={x(p.d)} y1={padT} x2={x(p.d)} y2={H - padB} stroke="#222d36" strokeWidth="1" />
|
||||
{p.alt != null && <rect x={x(p.d) - 3} y={y(p.alt) - 3} width="6" height="6" fill="#4fa8ff" />}
|
||||
<text x={x(p.d)} y={H - padB + 14} fill="#9fb3c0" fontSize="11" textAnchor="middle" fontFamily="monospace">{p.id}</text>
|
||||
{p.alt != null && <text x={x(p.d)} y={y(p.alt) - 7} fill="#4fa8ff" fontSize="10" textAnchor="middle" fontFamily="monospace">{p.alt}</text>}
|
||||
</g>
|
||||
))}
|
||||
{/* own aircraft at the left edge */}
|
||||
<polygon points={`${x(0) - 7},${y(alt)} ${x(0) + 7},${y(alt) - 5} ${x(0) + 7},${y(alt) + 5}`} fill="#ffce00" />
|
||||
<text x={x(0) + 10} y={y(alt) - 6} fill="#ffce00" fontSize="11" fontFamily="monospace">{Math.round(alt)}</text>
|
||||
</svg>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/* ---------------- engine instrument strip (EIS) ---------------- */
|
||||
function EisStrip({ V }) {
|
||||
const rpm = arr(V.engRpm);
|
||||
@@ -155,6 +209,17 @@ function EisStrip({ V }) {
|
||||
<Bar y={284} label="VAC" min={0} max={10} value={5}
|
||||
zones={[{ from: 4.5, to: 5.5, c: '#0c0' }]} />
|
||||
<FuelBar y={330} left={fuelL} right={fuelR} />
|
||||
{/* fuel totalizer (SYSTEM → DEC/INC/RST FUEL): pilot-set remaining + used */}
|
||||
{(() => {
|
||||
const rem = num(V.fuelTot) / KG_PER_GAL;
|
||||
const used = Math.max(0, (num(V.fuelMax) - num(V.fuelTot)) / KG_PER_GAL);
|
||||
return (<>
|
||||
<text x="8" y="392" fill="#39d3c0" fontSize="11">CALC</text>
|
||||
<text x="118" y="392" fill="#fff" fontSize="14" textAnchor="end">{rem.toFixed(1)}</text>
|
||||
<text x="124" y="392" fill="#7f8c97" fontSize="10">GAL</text>
|
||||
<text x="182" y="392" fill="#9aa" fontSize="10" textAnchor="end">USED {used.toFixed(0)}</text>
|
||||
</>);
|
||||
})()}
|
||||
<text x="8" y="412" fill="#39d3c0" fontSize="12">ENG</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>
|
||||
|
||||
@@ -57,6 +57,19 @@ const TILES = {
|
||||
dark: null,
|
||||
};
|
||||
|
||||
// TCAS target symbol: diamond coloured by threat (other/proximate/TA/RA), with
|
||||
// relative altitude (hundreds of ft, ± vs own ship) and a climb/descend arrow.
|
||||
const TCAS_COLOR = ['#cfd6dd', '#19d3ff', '#ffce00', '#ff3b3b'];
|
||||
function tcasSymbol(t) {
|
||||
const C = TCAS_COLOR[t.thr || 0];
|
||||
const filled = (t.thr || 0) >= 1;
|
||||
const arrow = t.vs > 0 ? '▲' : t.vs < 0 ? '▼' : '';
|
||||
const rel = (t.relAlt >= 0 ? '+' : '−') + String(Math.abs(Math.round(t.relAlt))).padStart(2, '0');
|
||||
const diamond = `<svg viewBox='0 0 16 16' width='16' height='16'><polygon points='8,1 15,8 8,15 1,8' fill='${filled ? C : 'none'}' stroke='${C}' stroke-width='2'/></svg>`;
|
||||
const html = `<div class='tcas-sym' style='color:${C}'>${diamond}<span class='tcas-lbl'>${rel}${arrow}</span></div>`;
|
||||
return L.marker([t.lat, t.lon], { icon: L.divIcon({ className: 'tcas-divicon', html, iconSize: [16, 16], iconAnchor: [8, 8] }), interactive: false, zIndexOffset: 1200 });
|
||||
}
|
||||
|
||||
// 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
|
||||
@@ -88,6 +101,8 @@ export default function MapView({ values, flightPlan, fp, inset = false, hud = t
|
||||
const aspLayerRef = useRef(null);
|
||||
const aspOnRef = useRef(false);
|
||||
const refreshAirspaceRef = useRef(null);
|
||||
const wxLayerRef = useRef(null);
|
||||
const trafficLayerRef = useRef(null);
|
||||
const baseRef = useRef(null);
|
||||
const terrRef = useRef(null);
|
||||
const zoomingRef = useRef(false);
|
||||
@@ -100,8 +115,10 @@ 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 airways = (mapMode?.airways | 0); // 0 off · 1 all · 2 Victor (low) · 3 Jet (high)
|
||||
const airspace = !!mapMode?.airspace;
|
||||
const traffic = !!mapMode?.traffic;
|
||||
const nexrad = !!mapMode?.nexrad;
|
||||
aspOnRef.current = airspace;
|
||||
const dcltrRef = useRef(dcltr);
|
||||
dcltrRef.current = dcltr;
|
||||
@@ -135,11 +152,13 @@ export default function MapView({ values, flightPlan, fp, inset = false, hud = t
|
||||
map.getPane('terrain').style.zIndex = 250;
|
||||
map.getPane('terrain').style.pointerEvents = 'none';
|
||||
|
||||
aspLayerRef.current = L.layerGroup().addTo(map); // airspace polygons (bottom overlay)
|
||||
wxLayerRef.current = L.layerGroup().addTo(map); // NEXRAD precip (bottom)
|
||||
aspLayerRef.current = L.layerGroup().addTo(map); // airspace polygons
|
||||
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);
|
||||
trafficLayerRef.current = L.layerGroup().addTo(map); // TCAS targets (top)
|
||||
|
||||
// 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).
|
||||
@@ -154,8 +173,11 @@ export default function MapView({ values, flightPlan, fp, inset = false, hud = t
|
||||
const segs = await res.json();
|
||||
layer.clearLayers();
|
||||
const labels = map.getZoom() >= 8;
|
||||
const mode = awyOnRef.current; // 1 all · 2 Victor (low) · 3 Jet (high)
|
||||
const seen = new Set();
|
||||
for (const sg of segs) {
|
||||
if (mode === 2 && sg.lyr !== 1) continue; // Victor-only: low-altitude airways
|
||||
if (mode === 3 && sg.lyr !== 2) continue; // Jet-only: high-altitude airways
|
||||
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);
|
||||
@@ -256,6 +278,26 @@ export default function MapView({ values, flightPlan, fp, inset = false, hud = t
|
||||
// redraw airspace when the AIRSPACE toggle changes
|
||||
useEffect(() => { refreshAirspaceRef.current && refreshAirspaceRef.current(); }, [airspace]); // eslint-disable-line
|
||||
|
||||
// NEXRAD precip cells (green/yellow/red) — toggled by the MFD NEXRAD softkey.
|
||||
useEffect(() => {
|
||||
const layer = wxLayerRef.current; if (!layer) return;
|
||||
layer.clearLayers();
|
||||
if (!nexrad) return;
|
||||
const col = { 1: '#1fa83a', 2: '#e6c200', 3: '#e23131' };
|
||||
for (const c of (values.wxCells || [])) {
|
||||
if (!isFinite(c.lat)) continue;
|
||||
L.circle([c.lat, c.lon], { radius: (c.r || 5) * 1852, stroke: false, fillColor: col[c.lvl] || col[1], fillOpacity: 0.32, interactive: false }).addTo(layer);
|
||||
}
|
||||
}, [nexrad, values.wxCells]); // eslint-disable-line
|
||||
|
||||
// TCAS traffic targets — toggled by the MFD TRAFFIC softkey.
|
||||
useEffect(() => {
|
||||
const layer = trafficLayerRef.current; if (!layer) return;
|
||||
layer.clearLayers();
|
||||
if (!traffic) return;
|
||||
for (const tgt of (values.traffic || [])) { if (isFinite(tgt.lat)) layer.addLayer(tcasSymbol(tgt)); }
|
||||
}, [traffic, values.traffic]); // 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 100–1000 ft below, transparent otherwise (G1000 TAWS colours). Only
|
||||
|
||||
@@ -5,7 +5,8 @@ import { num } from '../api/useXplane.js';
|
||||
// press the NRST softkey; it lists the closest airports / VORs / NDBs to the
|
||||
// aircraft with bearing + distance, straight from X-Plane's own nav data
|
||||
// (/api/nav/nearest). Tabs switch the feature type, like turning the FMS knob
|
||||
// through the NRST page group on the real unit.
|
||||
// through the NRST page group on the real unit. Each entry can be acted on:
|
||||
// load its frequency into the COM/NAV standby, or fly Direct-To it (manual p.23).
|
||||
const TABS = [
|
||||
{ id: 'apt', label: 'APT' },
|
||||
{ id: 'vor', label: 'VOR' },
|
||||
@@ -19,10 +20,11 @@ const freqStr = (f, type) => {
|
||||
return type === 'vor' ? (n / 100).toFixed(2) : String(n);
|
||||
};
|
||||
|
||||
export default function Nearest({ values, onClose, full = false }) {
|
||||
export default function Nearest({ xp, values: valuesProp, onClose, full = false }) {
|
||||
const values = xp?.values || valuesProp || {};
|
||||
const [type, setType] = useState('apt');
|
||||
const [rows, setRows] = useState([]);
|
||||
const lastRef = useRef(null);
|
||||
const [msg, setMsg] = useState('');
|
||||
const lat = num(values.lat), lon = num(values.lon);
|
||||
|
||||
useEffect(() => {
|
||||
@@ -36,11 +38,27 @@ export default function Nearest({ values, onClose, full = false }) {
|
||||
} catch { /* aborted / offline */ }
|
||||
};
|
||||
load();
|
||||
// Refresh as the aircraft moves (cheap server scan).
|
||||
timer = setInterval(load, 5000);
|
||||
return () => { abort.abort(); clearInterval(timer); };
|
||||
}, [type, Math.round(lat * 50), Math.round(lon * 50)]); // re-key on ~1nm moves
|
||||
|
||||
const flash = (t) => { setMsg(t); setTimeout(() => setMsg(''), 1800); };
|
||||
|
||||
// Fly Direct-To: a magenta leg from present position to the chosen feature.
|
||||
const directTo = (f) => {
|
||||
if (!xp || !isFinite(f.lat)) return;
|
||||
xp.fp.set({ name: 'ACTIVE', waypoints: [
|
||||
{ id: 'PPOS', lat, lon, type: 'USR' },
|
||||
{ id: f.id, lat: f.lat, lon: f.lon, type: f.type || (type === 'apt' ? 'APT' : type === 'vor' ? 'VOR' : 'NDB') },
|
||||
] });
|
||||
xp.command('direct');
|
||||
flash(`Direct-To ${f.id}`);
|
||||
onClose && onClose();
|
||||
};
|
||||
// Load a frequency into COM1 / NAV1 standby (freq units are 10 kHz, e.g. 11990).
|
||||
const toCom = (f) => { if (xp && f.com) { xp.setDataref('com1Sb', Math.round(f.com.freq * 100)); flash(`COM1 STBY ${f.com.freq.toFixed(3)}`); } };
|
||||
const toNav = (f) => { if (xp && f.freq) { xp.setDataref('nav1Sb', Math.round(num(f.freq))); flash(`NAV1 STBY ${(num(f.freq) / 100).toFixed(2)}`); } };
|
||||
|
||||
return (
|
||||
<div className={`nrst-window ${full ? 'full' : ''}`}>
|
||||
<div className="nrst-head">
|
||||
@@ -68,6 +86,12 @@ export default function Nearest({ values, onClose, full = false }) {
|
||||
<span className="apt-rwlbl">RNWY</span>
|
||||
<span className="apt-rw">{f.rwyFt ? `${f.rwyFt}FT` : '—'}</span>
|
||||
</div>
|
||||
{xp && (
|
||||
<div className="nrst-acts">
|
||||
{f.com && <button className="nrst-act" onClick={() => toCom(f)} title="→ COM1 standby">→COM</button>}
|
||||
<button className="nrst-act dto" onClick={() => directTo(f)} title="Direct-To">D→</button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
))
|
||||
: rows.map((f, i) => (
|
||||
@@ -76,10 +100,16 @@ export default function Nearest({ values, onClose, full = false }) {
|
||||
<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>}
|
||||
{xp && (
|
||||
<span className="nrst-acts">
|
||||
{type === 'vor' && f.freq && <button className="nrst-act" onClick={() => toNav(f)} title="→ NAV1 standby">→NAV</button>}
|
||||
<button className="nrst-act dto" onClick={() => directTo(f)} title="Direct-To">D→</button>
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
{msg && <div className="nrst-msg">{msg}</div>}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
+31
-12
@@ -63,7 +63,10 @@ function fmtEte(s) {
|
||||
// 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) {
|
||||
function vnavInfo(V, fp, cfg = { enabled: true, fpa: VNAV_FPA, offsetNm: 0 }) {
|
||||
if (!cfg.enabled) return null; // VNAV cancelled (CNCL VNV)
|
||||
const fpa = cfg.fpa || VNAV_FPA;
|
||||
const off = Math.max(0, cfg.offsetNm || 0);
|
||||
const wps = fp?.waypoints || [];
|
||||
const ai = Math.max(1, Math.min(wps.length - 1, fp?.activeLeg ?? 1));
|
||||
const alt = num(V.altitude);
|
||||
@@ -76,16 +79,17 @@ function vnavInfo(V, fp) {
|
||||
prevLat = wps[i].lat; prevLon = wps[i].lon;
|
||||
const tgt = num(wps[i].alt);
|
||||
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 tan = Math.tan((fpa * Math.PI) / 180);
|
||||
const d = Math.max(0, cum - off); // distance to level-off point
|
||||
const tMin = (d / gs) * 60;
|
||||
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 desiredAltNow = tgt + d * 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 todNm = d - 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 { wptId: wps[i].id, tgtAlt: tgt, dist: cum, vsReq, vsTgt, vDev, fpa, todSec };
|
||||
}
|
||||
}
|
||||
return null;
|
||||
@@ -100,7 +104,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, 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 }) {
|
||||
export default function PFD({ xp, 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, vnav: vnavCfg, svtOpts }) {
|
||||
const wrapRef = useRef(null);
|
||||
const svgRef = useRef(null);
|
||||
const [box, setBox] = useState(null);
|
||||
@@ -137,7 +141,7 @@ export default function PFD({ values: V, command, connected = true, svt = true,
|
||||
}, []);
|
||||
|
||||
const nav = activeNav(V, flightPlan);
|
||||
const vnav = vnavInfo(V, flightPlan);
|
||||
const vnav = vnavInfo(V, flightPlan, vnavCfg);
|
||||
// GPS phase annunciation: APR when an approach leg is active, TERM within 30 nm
|
||||
// of the destination, otherwise ENR (manual).
|
||||
const gpsPhase = (() => {
|
||||
@@ -160,7 +164,7 @@ export default function PFD({ values: V, command, connected = true, svt = true,
|
||||
<div className="pfd-wrap" ref={wrapRef}>
|
||||
{svt && box && (
|
||||
<div className="svt-pos" style={box}>
|
||||
<Suspense fallback={<div className="svt-fallback" />}><SVT values={V} /></Suspense>
|
||||
<Suspense fallback={<div className="svt-fallback" />}><SVT values={V} flightPlan={flightPlan} opts={svtOpts} /></Suspense>
|
||||
</div>
|
||||
)}
|
||||
{inset && insetBox && (
|
||||
@@ -182,7 +186,7 @@ export default function PFD({ values: V, command, connected = true, svt = true,
|
||||
<RadioBar V={V} onTune={command ? setTune : null} />
|
||||
{nav && <NavStatus nav={nav} />}
|
||||
{vnav && <VnavBox vnav={vnav} />}
|
||||
<Attitude V={V} svt={svt} />
|
||||
<Attitude V={V} svt={svt} hrznHdg={svtOpts?.hrznHdg} />
|
||||
<AFCS V={V} />
|
||||
<Marker V={V} />
|
||||
<AirspeedTape V={V} ias={iasS} />
|
||||
@@ -203,7 +207,7 @@ export default function PFD({ values: V, command, connected = true, svt = true,
|
||||
</g>
|
||||
)}
|
||||
</svg>
|
||||
{nrst && <Nearest values={V} onClose={onCloseNrst} />}
|
||||
{nrst && <Nearest xp={xp} onClose={onCloseNrst} />}
|
||||
{tmr && <TimerRef values={V} onClose={onCloseTmr} minimums={minimums} onMinimums={onMinimums} />}
|
||||
{dme && <DmeWindow V={V} onClose={onCloseDme} />}
|
||||
{alerts && <AlertsWindow V={V} onClose={onCloseAlerts} />}
|
||||
@@ -381,7 +385,21 @@ function AFCS({ V }) {
|
||||
}
|
||||
|
||||
/* ---------------- attitude + flight director ---------------- */
|
||||
function Attitude({ V, svt }) {
|
||||
// Heading reference marks along the horizon line (HRZN HDG softkey).
|
||||
function hdgMarks(cx, cy, hdg) {
|
||||
const PX = 3.4, out = []; // px per degree across the horizon
|
||||
for (let k = -40; k <= 40; k += 10) {
|
||||
const d = ((Math.round(hdg / 10) * 10 + k) % 360 + 360) % 360;
|
||||
const off = (((d - hdg + 540) % 360) - 180) * PX;
|
||||
const x = cx + off;
|
||||
const big = d % 30 === 0;
|
||||
out.push(<line key={'h' + k} x1={x} y1={cy - (big ? 13 : 8)} x2={x} y2={cy} stroke="#fff" strokeWidth="1.5" />);
|
||||
if (big) { const lbl = d === 0 ? 'N' : d === 90 ? 'E' : d === 180 ? 'S' : d === 270 ? 'W' : d / 10; out.push(<text key={'t' + k} x={x} y={cy - 16} fill="#fff" fontSize="13" textAnchor="middle" fontFamily="'Saira Condensed',monospace">{lbl}</text>); }
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
function Attitude({ V, svt, hrznHdg }) {
|
||||
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;
|
||||
@@ -430,6 +448,7 @@ function Attitude({ V, svt }) {
|
||||
{!svt && <rect x={cx - 800} y={cy} width={1600} height={1100} fill="url(#ground)" />}
|
||||
{!svt && <rect x={cx - 800} y={cy - 1.5} width={1600} height={3} fill="#fff" />}
|
||||
{pitchLadder(cx, cy)}
|
||||
{hrznHdg && hdgMarks(cx, cy, ((num(V.heading) % 360) + 360) % 360)}
|
||||
</g>
|
||||
</g>
|
||||
{/* flight director command bars — magenta filled chevron (single cue) */}
|
||||
|
||||
@@ -83,11 +83,27 @@ function cameraPitchForAircraft(aircraftPitchDeg) {
|
||||
return Math.max(60, Math.min(85, pitch));
|
||||
}
|
||||
|
||||
export default function SVT({ values }) {
|
||||
// Flight-plan route as a GeoJSON LineString (drawn on the terrain for PATHWAY).
|
||||
function routeGeo(plan) {
|
||||
const wps = plan?.waypoints || [];
|
||||
return { type: 'Feature', geometry: { type: 'LineString', coordinates: wps.map((w) => [w.lon, w.lat]) } };
|
||||
}
|
||||
|
||||
// Toggle the PATHWAY route line and APTSIGNS runway labels (default on).
|
||||
function setSvtVisibility(map, opts) {
|
||||
if (!map) return;
|
||||
const set = (id, vis) => { try { if (map.getLayer(id)) map.setLayoutProperty(id, 'visibility', vis ? 'visible' : 'none'); } catch { /* not ready */ } };
|
||||
set('fpl-line', !opts || opts.pathway !== false);
|
||||
['rwy-fill', 'rwy-line', 'rwy-num'].forEach((id) => set(id, !opts || opts.aptSigns !== false));
|
||||
}
|
||||
|
||||
export default function SVT({ values, flightPlan, opts }) {
|
||||
const elRef = useRef(null);
|
||||
const mapRef = useRef(null);
|
||||
const dataRef = useRef(values);
|
||||
dataRef.current = values;
|
||||
const planRef = useRef(flightPlan); planRef.current = flightPlan;
|
||||
const optsRef = useRef(opts); optsRef.current = opts;
|
||||
|
||||
useEffect(() => {
|
||||
let map;
|
||||
@@ -131,6 +147,12 @@ export default function SVT({ values }) {
|
||||
},
|
||||
paint: { 'text-color': '#fff', 'text-halo-color': '#000', 'text-halo-width': 1.4 },
|
||||
});
|
||||
// PATHWAY: the active flight-plan route, draped magenta on the terrain.
|
||||
map.addSource('fplroute', { type: 'geojson', data: routeGeo(planRef.current) });
|
||||
map.addLayer({ id: 'fpl-line', type: 'line', source: 'fplroute',
|
||||
layout: { 'line-cap': 'round', 'line-join': 'round' },
|
||||
paint: { 'line-color': '#ff20ff', 'line-width': 3, 'line-opacity': 0.9 } });
|
||||
setSvtVisibility(map, optsRef.current); // honour PATHWAY / APTSIGNS from the start
|
||||
let last = null;
|
||||
const refresh = async () => {
|
||||
const v = dataRef.current, lat = num(v.lat), lon = num(v.lon);
|
||||
@@ -186,6 +208,14 @@ export default function SVT({ values }) {
|
||||
return () => { cancelAnimationFrame(raf); clearInterval(rwyTimer); map.remove(); mapRef.current = null; };
|
||||
}, []); // eslint-disable-line
|
||||
|
||||
// Keep the PATHWAY route in sync with the flight plan, and apply PATHWAY /
|
||||
// APTSIGNS visibility when the softkeys toggle them.
|
||||
useEffect(() => {
|
||||
const m = mapRef.current;
|
||||
if (m && m.getSource && m.getSource('fplroute')) { try { m.getSource('fplroute').setData(routeGeo(flightPlan)); } catch { /* not ready */ } }
|
||||
}, [flightPlan]);
|
||||
useEffect(() => { setSvtVisibility(mapRef.current, opts); }, [opts]); // eslint-disable-line
|
||||
|
||||
// Bank: rotate the whole terrain canvas opposite to aircraft roll; scale up so
|
||||
// the corners stay covered while rotated.
|
||||
const roll = num(values.roll);
|
||||
|
||||
@@ -0,0 +1,100 @@
|
||||
import React, { useState } from 'react';
|
||||
import { num } from '../../api/useXplane.js';
|
||||
|
||||
// ============================================================================
|
||||
// Citation X — Honeywell Primus 2000 Autopilot / Flight Guidance Controller.
|
||||
// Exact button layout per the manual (pages 26-28):
|
||||
// col1: HDG NAV APP BC col2: ALT VNAV BANK STBY
|
||||
// col3: FLC C/O VS center: PITCH WHEEL (NOSE DN / NOSE UP)
|
||||
// col4: AP YD M TRIM PFD SEL
|
||||
// Mode lamps read the per-mode *_status datarefs (0 off · 1 armed · 2 active),
|
||||
// the same reliable source the PFD uses. Buttons fire X-Plane AP commands.
|
||||
// ============================================================================
|
||||
|
||||
export default function CitAP({ xp }) {
|
||||
const V = xp.values || {};
|
||||
const cmd = xp.command;
|
||||
const stat = (k) => num(V[k]); // 0 off · 1 armed · 2 active
|
||||
const apOn = num(V.apEngaged) > 0 || num(V.apMode) >= 2;
|
||||
const ydOn = num(V.ydOn) > 0 || apOn;
|
||||
const [bank, setBank] = useState(false); // BANK (low-bank 17°) — annunc only
|
||||
const [mtrim, setMtrim] = useState(true); // M TRIM (mach trim) — annunc only
|
||||
const [pfdSel, setPfdSel] = useState('PILOT'); // PFD SEL — pilot/copilot guidance
|
||||
|
||||
// active mode strings for the annunciator bar (matches the PFD)
|
||||
const lateral = stat('aprStatus') ? ['APP', stat('aprStatus')] : stat('navStatus') ? ['NAV', stat('navStatus')]
|
||||
: stat('bcStatus') ? ['BC', stat('bcStatus')] : stat('hdgStatus') ? ['HDG', stat('hdgStatus')] : ['ROL', 2];
|
||||
const vertical = stat('gsStatus') ? ['GS', stat('gsStatus')] : stat('vnavStatus') ? ['VNAV', stat('vnavStatus')]
|
||||
: stat('flcStatus') ? ['FLC', stat('flcStatus')] : stat('vsStatus') ? ['VS', stat('vsStatus')]
|
||||
: stat('altStatus') ? ['ALT', stat('altStatus')] : ['PIT', 2];
|
||||
|
||||
// A mode button: green lamp when its status is active(2), amber when armed(1).
|
||||
const lamp = (k) => (stat(k) >= 2 ? 'active' : stat(k) === 1 ? 'armed' : '');
|
||||
const Btn = ({ label, cmd: c, on, cls = '', onClick }) => (
|
||||
<button className={`citap-btn ${on || cls} ${cls}`} onClick={onClick || (() => c && cmd(c))}>
|
||||
<span className="citap-arrow">▸</span>{label}
|
||||
</button>
|
||||
);
|
||||
|
||||
const sel = num(V.apAltBug);
|
||||
|
||||
return (
|
||||
<div className="cit-screen citap-screen">
|
||||
{/* selected references row (alt / hdg / spd / vs) */}
|
||||
<div className="citap-refs">
|
||||
<div><span>ALT SEL</span><b>{Math.round(num(V.apAltBug))}</b></div>
|
||||
<div><span>HDG</span><b>{String(Math.round(num(V.apHdgBug)) % 360).padStart(3, '0')}</b></div>
|
||||
<div><span>IAS/M</span><b>{num(V.mach) >= 0.4 ? num(V.mach).toFixed(2) : Math.round(num(V.apSpdBug))}</b></div>
|
||||
<div><span>VS</span><b>{Math.round(num(V.apVsBug))}</b></div>
|
||||
</div>
|
||||
|
||||
{/* FMA annunciator bar (active = green, armed = white) */}
|
||||
<div className="citap-fma">
|
||||
<span className={lateral[1] >= 2 ? 'fma-act' : 'fma-arm'}>{lateral[0]}</span>
|
||||
<span className="fma-ap">{apOn ? 'AP' : 'FD'}{ydOn ? ' · YD' : ''}</span>
|
||||
<span className={vertical[1] >= 2 ? 'fma-act' : 'fma-arm'}>{vertical[0]}</span>
|
||||
</div>
|
||||
|
||||
<div className="citap-panel">
|
||||
<div className="citap-col">
|
||||
<Btn label="HDG" cmd="hdg" on={lamp('hdgStatus')} />
|
||||
<Btn label="NAV" cmd="nav" on={lamp('navStatus')} />
|
||||
<Btn label="APP" cmd="apr" on={lamp('aprStatus')} />
|
||||
<Btn label="BC" cmd="backCourse" on={lamp('bcStatus')} />
|
||||
</div>
|
||||
<div className="citap-col">
|
||||
<Btn label="ALT" cmd="altHold" on={lamp('altStatus')} />
|
||||
<Btn label="VNAV" cmd="vnav" on={lamp('vnavStatus')} />
|
||||
<Btn label="BANK" on={bank ? 'active' : ''} onClick={() => setBank((v) => !v)} />
|
||||
<Btn label="STBY" cmd="apStby" onClick={() => cmd('apStby')} />
|
||||
</div>
|
||||
<div className="citap-col">
|
||||
<Btn label="FLC" cmd="flc" on={lamp('flcStatus')} />
|
||||
<Btn label="C/O" cls="dim" onClick={() => {}} />
|
||||
<Btn label="VS" cmd="vs" on={lamp('vsStatus')} />
|
||||
</div>
|
||||
|
||||
{/* PITCH WHEEL — VS rate (in VS) or IAS/Mach target (in FLC) */}
|
||||
<div className="citap-wheel">
|
||||
<div className="citap-wlbl">NOSE UP</div>
|
||||
<button className="citap-wbtn" onClick={() => cmd(stat('flcStatus') ? 'spdDown' : 'vsUp')}>▲</button>
|
||||
<div className="citap-wheelface" />
|
||||
<button className="citap-wbtn" onClick={() => cmd(stat('flcStatus') ? 'spdUp' : 'vsDown')}>▼</button>
|
||||
<div className="citap-wlbl">NOSE DN</div>
|
||||
</div>
|
||||
|
||||
<div className="citap-col citap-master">
|
||||
<Btn label="AP" cmd="apToggle" on={apOn ? 'active' : ''} />
|
||||
<Btn label="YD" cmd="yawDamper" on={ydOn ? 'active' : ''} />
|
||||
<Btn label="M TRIM" on={mtrim ? 'active' : ''} onClick={() => setMtrim((v) => !v)} />
|
||||
<Btn label="PFD SEL" onClick={() => setPfdSel((p) => (p === 'PILOT' ? 'COPILOT' : 'PILOT'))} />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="citap-foot">
|
||||
AP MASTER engages Yaw Damper automatically · PITCH WHEEL sets V/S rate or FLC speed ·
|
||||
PFD SEL: <b>{pfdSel}</b> guidance{bank ? ' · LOW BANK 17°' : ''}{mtrim ? ' · MACH TRIM' : ''}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,15 @@
|
||||
import React from 'react';
|
||||
import CitPFD from './CitPFD.jsx';
|
||||
import CitMFD from './CitMFD.jsx';
|
||||
|
||||
// Side-by-side PFD + MFD — the two pilot tubes of the Citation X panel on one
|
||||
// tablet screen (landscape). Each keeps its own bezel/soft-keys; they scale to
|
||||
// fill half the width like the real instrument panel (DU-870 displays).
|
||||
export default function CitDuo({ xp, navSrc }) {
|
||||
return (
|
||||
<div className="cit-duo">
|
||||
<div className="cit-duo-half"><CitPFD xp={xp} navSrc={navSrc} /></div>
|
||||
<div className="cit-duo-half"><CitMFD xp={xp} /></div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,206 @@
|
||||
import React, { useState } from 'react';
|
||||
import { num } from '../../api/useXplane.js';
|
||||
import { useEased } from '../../api/ease.js';
|
||||
|
||||
// ============================================================================
|
||||
// Citation X — Engine Indicating & Crew Alerting System (EICAS).
|
||||
// Built against the manual (pages 34-35):
|
||||
// 1 Oil Temp · 2 Oil Press · 3 Fuel Qty · 4 Fuel Flow total · 5 Fuel Flow/eng
|
||||
// 6 Electrical (Volts/Amps BUS1/2) · 7 Hydraulics A/B · 8 LE slat status
|
||||
// 9 CAS scroll · 11 Control positions · 16 Flaps · 17 CAS page
|
||||
// 18 STAB trim · 19/20 Fan RPM (N1) · 21 ITT
|
||||
// Softkeys: NORM · FUEL/HYD · ELEC · CTRL POS · ENG (per manual).
|
||||
// ============================================================================
|
||||
|
||||
const arr = (x, i) => (Array.isArray(x) ? num(x[i]) : num(x));
|
||||
const PPH = (kgs) => Math.round(kgs * 7936.6); // kg/s → lb/hr
|
||||
const LB = (kg) => Math.round(kg * 2.20462);
|
||||
|
||||
// vertical bar gauge (FAN% / ITT) with digital readout + redline
|
||||
function VBar({ x, w, h, val, min, max, red, decimals = 0 }) {
|
||||
const f = (v) => h - ((v - min) / (max - min)) * h; // value → y
|
||||
const py = f(Math.max(min, Math.min(max, val)));
|
||||
const overRed = red != null && val >= red;
|
||||
return (
|
||||
<g transform={`translate(${x} 0)`}>
|
||||
<rect x={0} y={0} width={w} height={h} fill="#0c1116" stroke="#2a3138" />
|
||||
{red != null && <rect x={0} y={0} width={w} height={f(red)} fill="#3a1414" />}
|
||||
{red != null && <line x1={0} y1={f(red)} x2={w} y2={f(red)} stroke="#c0392b" strokeWidth="2" />}
|
||||
<rect x={2} y={py} width={w - 4} height={h - py} fill={overRed ? '#c0392b' : '#13a800'} />
|
||||
<polygon points={`${w},${py} ${w - 9},${py - 6} ${w - 9},${py + 6}`} fill="#fff" />
|
||||
<rect x={-3} y={h + 4} width={w + 6} height={22} fill="#000" stroke="#5a6168" />
|
||||
<text x={w / 2} y={h + 20} fontSize="15" fill={overRed ? '#ff5a4d' : '#fff'} textAnchor="middle" fontWeight="700">{val.toFixed(decimals)}</text>
|
||||
</g>
|
||||
);
|
||||
}
|
||||
|
||||
// small horizontal bar pair (OIL °C / OIL PSI)
|
||||
function HBar({ y, val, max, red }) {
|
||||
const f = Math.max(0, Math.min(1, val / max));
|
||||
return (
|
||||
<g transform={`translate(0 ${y})`}>
|
||||
<rect x={0} y={0} width={44} height={10} fill="#0c1116" stroke="#2a3138" />
|
||||
<rect x={0} y={0} width={44 * f} height={10} fill={red && val >= red ? '#c0392b' : '#13a800'} />
|
||||
</g>
|
||||
);
|
||||
}
|
||||
|
||||
export default function CitEICAS({ xp }) {
|
||||
const V = xp.values || {};
|
||||
const [page, setPage] = useState('norm'); // norm | fuel | elec | ctrl | eng
|
||||
|
||||
const n1 = [useEased(arr(V.n1, 0), 0.16), useEased(arr(V.n1, 1), 0.16)];
|
||||
const itt = [useEased(arr(V.itt, 0), 0.2), useEased(arr(V.itt, 1), 0.2)];
|
||||
const oilT = [arr(V.oilTemp, 0), arr(V.oilTemp, 1)];
|
||||
const oilP = [arr(V.oilPress, 0), arr(V.oilPress, 1)];
|
||||
const ff = [arr(V.fuelFlow, 0), arr(V.fuelFlow, 1)];
|
||||
const fq = [arr(V.fuelQty, 0), arr(V.fuelQty, 1)];
|
||||
const fqCtr = Array.isArray(V.fuelQty) && V.fuelQty.length > 2 ? arr(V.fuelQty, 2) : 0;
|
||||
const volts = [arr(V.volts, 0), arr(V.volts, 1)];
|
||||
const amps = [Math.round(arr(V.genAmps, 0)), Math.round(arr(V.genAmps, 1) || arr(V.genAmps, 0))];
|
||||
const hyd = [arr(V.hydPress, 0), arr(V.hydPress, 1)];
|
||||
const stab = (num(V.elevTrim) * 12).toFixed(1);
|
||||
const flapDeg = Math.round(num(V.flapRatio) * 35);
|
||||
const n2 = [arr(V.n2, 0), arr(V.n2, 1)];
|
||||
const rat = Math.round(num(V.oat));
|
||||
const slat = num(V.slatRatio);
|
||||
|
||||
// ── CAS messages (#17) — four severity levels per the manual ──────────────
|
||||
const cas = [];
|
||||
const running = (n1[0] + n1[1]) / 2 > 20;
|
||||
if (num(V.parkBrake) > 0.5) cas.push({ t: 'PARK BRAKE ON', lvl: 'status' });
|
||||
if (num(V.gearHandle) < 0.5 && num(V.airspeed) < 60) cas.push({ t: 'GEAR NOT DOWN', lvl: 'status' });
|
||||
if (num(V.speedbrake) > 0.1) cas.push({ t: 'SPEEDBRAKES EXT', lvl: 'caution' });
|
||||
if (running && (oilP[0] < 20 || oilP[1] < 20)) cas.push({ t: 'OIL PRESS LOW', lvl: 'warning' });
|
||||
if (itt[0] > 870 || itt[1] > 870) cas.push({ t: 'ITT HIGH', lvl: 'warning' });
|
||||
if (volts[0] < 24 || volts[1] < 24) cas.push({ t: 'DC GEN OFF', lvl: 'caution' });
|
||||
if (LB(fq[0] + fq[1] + fqCtr) < 800) cas.push({ t: 'FUEL LOW', lvl: 'caution' });
|
||||
if (!running) cas.push({ t: 'ENGINES OFF', lvl: 'status' });
|
||||
cas.push({ t: 'END', lvl: 'status' });
|
||||
const casColor = { warning: '#ff3b30', caution: '#ffb000', advisory: '#19c3e0', status: '#cfd6dc' };
|
||||
|
||||
const SK = ({ id, label }) => (
|
||||
<button className={`cit-sk ${page === id ? 'on' : ''}`} onClick={() => setPage(id)}>{label}</button>
|
||||
);
|
||||
|
||||
return (
|
||||
<div className="cit-screen">
|
||||
<svg className="cit-eicas" viewBox="0 0 760 900" preserveAspectRatio="xMidYMid meet">
|
||||
<rect x={0} y={0} width={760} height={900} fill="#05080b" />
|
||||
|
||||
{/* ── engine top band ─────────────────────────────────────────── */}
|
||||
<text x={86} y={28} fontSize="15" fill="#cfd6dc" textAnchor="middle">FAN%</text>
|
||||
<text x={250} y={28} fontSize="15" fill="#cfd6dc" textAnchor="middle">ITT °C</text>
|
||||
<g transform="translate(40 44)">
|
||||
<VBar x={0} w={36} h={150} val={n1[0]} min={0} max={110} red={100} decimals={1} />
|
||||
<VBar x={56} w={36} h={150} val={n1[1]} min={0} max={110} red={100} decimals={1} />
|
||||
{/* scale labels */}
|
||||
{[100, 70, 50, 30].map((s, i) => <text key={s} x={48} y={150 - (s / 110) * 150 + 5} fontSize="11" fill="#7d878e" textAnchor="middle">{s}</text>)}
|
||||
</g>
|
||||
<g transform="translate(204 44)">
|
||||
<VBar x={0} w={36} h={150} val={itt[0]} min={0} max={950} red={888} />
|
||||
<VBar x={56} w={36} h={150} val={itt[1]} min={0} max={950} red={888} />
|
||||
{[900, 700, 500, 300, 100].map((s) => <text key={s} x={48} y={150 - (s / 950) * 150 + 4} fontSize="10" fill="#7d878e" textAnchor="middle">{s}</text>)}
|
||||
</g>
|
||||
{/* N2 (TURB%) + RAT digital under engines */}
|
||||
<text x={86} y={232} fontSize="12" fill="#13a800" textAnchor="middle">{n2[0].toFixed(0)} TURB% {n2[1].toFixed(0)}</text>
|
||||
<text x={250} y={232} fontSize="12" fill="#cfd6dc" textAnchor="middle">RAT {rat}°C</text>
|
||||
|
||||
{/* OIL °C / OIL PSI (#1,#2) */}
|
||||
<text x={420} y={28} fontSize="14" fill="#cfd6dc" textAnchor="middle">OIL °C</text>
|
||||
<text x={530} y={28} fontSize="14" fill="#cfd6dc" textAnchor="middle">OIL PSI</text>
|
||||
<g transform="translate(376 44)"><HBar y={0} val={oilT[0]} max={150} red={130} /><HBar y={16} val={oilT[1]} max={150} red={130} /></g>
|
||||
<g transform="translate(488 44)"><HBar y={0} val={oilP[0]} max={120} red={null} /><HBar y={16} val={oilP[1]} max={120} red={null} /></g>
|
||||
<text x={420} y={56} fontSize="12" fill="#fff" textAnchor="middle">{Math.round(oilT[0])} / {Math.round(oilT[1])}</text>
|
||||
<text x={530} y={56} fontSize="12" fill="#fff" textAnchor="middle">{Math.round(oilP[0])} / {Math.round(oilP[1])}</text>
|
||||
|
||||
{/* ── FUEL (#3,#4,#5) ──────────────────────────────────────────── */}
|
||||
<g transform="translate(370 96)">
|
||||
<rect x={0} y={0} width={350} height={92} fill="none" stroke="#2a3138" />
|
||||
<text x={175} y={18} fontSize="14" fill="#cfd6dc" textAnchor="middle">FUEL</text>
|
||||
<text x={70} y={40} fontSize="13" fill="#13a800" textAnchor="middle">{PPH(ff[0])}</text>
|
||||
<text x={175} y={40} fontSize="12" fill="#9aa6ad" textAnchor="middle">FLOW PPH</text>
|
||||
<text x={280} y={40} fontSize="13" fill="#13a800" textAnchor="middle">{PPH(ff[1])}</text>
|
||||
<text x={70} y={64} fontSize="12" fill="#9aa6ad" textAnchor="middle">QTY</text>
|
||||
<text x={175} y={64} fontSize="15" fill="#fff" textAnchor="middle">{LB(fq[0] + fq[1] + fqCtr)}</text>
|
||||
<text x={280} y={64} fontSize="12" fill="#9aa6ad" textAnchor="middle">LBS</text>
|
||||
<text x={50} y={86} fontSize="13" fill="#fff" textAnchor="middle">{LB(fq[0])}</text>
|
||||
<text x={175} y={86} fontSize="13" fill="#fff" textAnchor="middle">{LB(fqCtr)}</text>
|
||||
<text x={300} y={86} fontSize="13" fill="#fff" textAnchor="middle">{LB(fq[1])}</text>
|
||||
{page === 'fuel' && <text x={175} y={104} fontSize="11" fill="#19c3e0" textAnchor="middle">FUEL TEMP L {rat - 2}° R {rat - 2}°</text>}
|
||||
</g>
|
||||
|
||||
{/* ── ELECTRICAL (#6) ──────────────────────────────────────────── */}
|
||||
<g transform="translate(370 232)">
|
||||
<text x={175} y={0} fontSize="14" fill="#cfd6dc" textAnchor="middle">ELECTRICAL</text>
|
||||
<text x={40} y={22} fontSize="14" fill="#13a800">{volts[0].toFixed(0)}</text>
|
||||
<text x={175} y={22} fontSize="12" fill="#9aa6ad" textAnchor="middle">DC VOLTS</text>
|
||||
<text x={300} y={22} fontSize="14" fill="#13a800" textAnchor="end">{volts[1].toFixed(0)}</text>
|
||||
<text x={40} y={42} fontSize="14" fill="#13a800">{amps[0]}</text>
|
||||
<text x={175} y={42} fontSize="12" fill="#9aa6ad" textAnchor="middle">DC AMPS</text>
|
||||
<text x={300} y={42} fontSize="14" fill="#13a800" textAnchor="end">{amps[1]}</text>
|
||||
{page === 'elec' && <text x={175} y={62} fontSize="11" fill="#19c3e0" textAnchor="middle">BATT {arr(V.battTemp, 0)}°C {volts[0].toFixed(1)}V</text>}
|
||||
</g>
|
||||
|
||||
{/* ── HYDRAULICS (#7) + slat (#8) ──────────────────────────────── */}
|
||||
<g transform="translate(370 332)">
|
||||
<text x={175} y={0} fontSize="14" fill="#cfd6dc" textAnchor="middle">HYDRAULICS</text>
|
||||
<text x={90} y={20} fontSize="12" fill="#9aa6ad" textAnchor="middle">A</text>
|
||||
<text x={260} y={20} fontSize="12" fill="#9aa6ad" textAnchor="middle">B</text>
|
||||
<text x={50} y={20} fontSize="12" fill="#9aa6ad">PSI</text>
|
||||
<text x={120} y={20} fontSize="14" fill="#13a800" textAnchor="end">{Math.round(hyd[0])}</text>
|
||||
<text x={300} y={20} fontSize="14" fill="#13a800" textAnchor="end">{Math.round(hyd[1])}</text>
|
||||
{/* leading-edge slat status chevron */}
|
||||
<polyline points="60,52 175,38 290,52" fill="none" stroke={slat > 0.05 && slat < 0.95 ? '#ffb000' : slat >= 0.95 ? '#fff' : '#2a3138'} strokeWidth="6" />
|
||||
</g>
|
||||
|
||||
{/* ── STAB trim (#18) + FLAPS (#16) (lower left) ───────────────── */}
|
||||
<g transform="translate(40 300)">
|
||||
<text x={40} y={0} fontSize="13" fill="#cfd6dc" textAnchor="middle">STAB</text>
|
||||
<text x={40} y={18} fontSize="16" fill="#fff" textAnchor="middle">{stab}</text>
|
||||
{/* simple arc dial */}
|
||||
<path d="M10 60 A40 40 0 0 1 70 60" fill="none" stroke="#2a3138" strokeWidth="3" />
|
||||
<line x1={40} y1={60} x2={40 + Math.sin(num(V.elevTrim) * 1.2) * 34} y2={60 - Math.cos(num(V.elevTrim) * 1.2) * 34} stroke="#13a800" strokeWidth="3" />
|
||||
</g>
|
||||
<g transform="translate(40 400)">
|
||||
<text x={40} y={0} fontSize="13" fill="#cfd6dc" textAnchor="middle">FLAPS</text>
|
||||
{[0, 5, 15, 35].map((d, i) => <text key={d} x={88} y={16 + i * 16} fontSize="11" fill={flapDeg >= d - 2 && flapDeg <= d + 2 ? '#13a800' : '#7d878e'} textAnchor="end">{d}</text>)}
|
||||
<line x1={10} y1={12} x2={10 + Math.cos(-flapDeg / 35 * 1.2) * 30} y2={12 - Math.sin(-flapDeg / 35 * 1.2) * 30} stroke="#13a800" strokeWidth="4" />
|
||||
</g>
|
||||
|
||||
{/* ── control positions overlay (#11) ──────────────────────────── */}
|
||||
{page === 'ctrl' && (
|
||||
<g transform="translate(40 220)">
|
||||
<text x={0} y={0} fontSize="13" fill="#19c3e0">CTRL POS</text>
|
||||
{[['AIL', num(V.ailDefl)], ['ELEV', num(V.elevDefl)], ['RUD', num(V.rudDefl)]].map(([l, v], i) => (
|
||||
<g key={l} transform={`translate(0 ${16 + i * 22})`}>
|
||||
<text x={0} y={6} fontSize="11" fill="#9aa6ad">{l}</text>
|
||||
<rect x={44} y={0} width={120} height={8} fill="#0c1116" stroke="#2a3138" />
|
||||
<rect x={44 + 60 + v * 58} y={0} width={3} height={8} fill="#13a800" />
|
||||
<line x1={44 + 60} y1={-2} x2={44 + 60} y2={10} stroke="#5a6168" />
|
||||
</g>
|
||||
))}
|
||||
</g>
|
||||
)}
|
||||
|
||||
{/* ── CAS messages (#17) ───────────────────────────────────────── */}
|
||||
<g transform="translate(40 470)">
|
||||
<rect x={0} y={0} width={300} height={310} fill="#070b0f" stroke="#2a3138" />
|
||||
{cas.slice(0, 14).map((m, i) => (
|
||||
<text key={i} x={10} y={22 + i * 21} fontSize="14" fill={casColor[m.lvl]} fontWeight={m.lvl === 'warning' ? '700' : '400'}>{m.t}</text>
|
||||
))}
|
||||
</g>
|
||||
<text x={420} y={500} fontSize="12" fill="#7d878e">PAGE: {page.toUpperCase()}</text>
|
||||
</svg>
|
||||
|
||||
<div className="cit-bezel cit-eicas-sk">
|
||||
<SK id="norm" label="NORM" />
|
||||
<SK id="fuel" label="FUEL/HYD" />
|
||||
<SK id="elec" label="ELEC" />
|
||||
<SK id="ctrl" label="CTRL POS" />
|
||||
<SK id="eng" label="ENG" />
|
||||
<button className="cit-sk" title="CAS scroll">MSG</button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,242 @@
|
||||
import React, { useState, useEffect, useRef } from 'react';
|
||||
import { num } from '../../api/useXplane.js';
|
||||
import { useEased, useEasedAngle } from '../../api/ease.js';
|
||||
|
||||
// ============================================================================
|
||||
// Citation X — Multi-Function Display (Honeywell Primus 2000 arc map).
|
||||
// Built against the manual (pages 32-33):
|
||||
// 1 Heading bug · 2 Heading · 3 Compass arc · 4 FMS source · 5 future leg (white)
|
||||
// 6 active leg (magenta) · 7 range arc · 8 ETE/SAT/TAS/GSPD group · 9 RNG
|
||||
// 10 V-SPEEDS · 11 EICAS SYS · 12 ET/FT timer · 13 MFD setup (TRAFFIC/TERRAIN/
|
||||
// APTS/VOR) · 14 PFD setup · 15 RTN · 16 WX status · 17 ownship · 18 airport
|
||||
// 19 navaid · 20 digital heading bug
|
||||
// ============================================================================
|
||||
|
||||
const RNGS = [10, 20, 40, 80, 160];
|
||||
const mod360 = (d) => ((d % 360) + 360) % 360;
|
||||
const toRad = (d) => (d * Math.PI) / 180;
|
||||
// great-circle distance (NM) + initial bearing (deg) from a→b
|
||||
function geo(aLat, aLon, bLat, bLon) {
|
||||
const φ1 = toRad(aLat), φ2 = toRad(bLat), dφ = toRad(bLat - aLat), dλ = toRad(bLon - aLon);
|
||||
const h = Math.sin(dφ / 2) ** 2 + Math.cos(φ1) * Math.cos(φ2) * Math.sin(dλ / 2) ** 2;
|
||||
const dist = 3440.065 * 2 * Math.atan2(Math.sqrt(h), Math.sqrt(1 - h));
|
||||
const y = Math.sin(dλ) * Math.cos(φ2);
|
||||
const x = Math.cos(φ1) * Math.sin(φ2) - Math.sin(φ1) * Math.cos(φ2) * Math.cos(dλ);
|
||||
return { dist, brg: mod360((Math.atan2(y, x) * 180) / Math.PI) };
|
||||
}
|
||||
|
||||
export default function CitMFD({ xp }) {
|
||||
const V = xp.values || {};
|
||||
const fp = xp.flightPlan || { waypoints: [] };
|
||||
const [rng, setRng] = useState(40);
|
||||
const [ov, setOv] = useState({ traffic: true, terrain: false, apts: true, vor: true });
|
||||
const [setup, setSetup] = useState(null); // null | 'mfd' | 'eicas' | 'pfd'
|
||||
const [vspd, setVspd] = useState(false);
|
||||
const [baroUnit, setBaroUnit] = useState('in'); // PFD SETUP: IN / HPA
|
||||
const [eicasSub, setEicasSub] = useState('fuel'); // EICAS SYS subset page
|
||||
const [et, setEt] = useState(0);
|
||||
const etRun = useRef(false);
|
||||
useEffect(() => { const id = setInterval(() => etRun.current && setEt((t) => t + 1), 1000); return () => clearInterval(id); }, []);
|
||||
|
||||
// smooth ownship + compass (same rAF glide as the G1000 map)
|
||||
const lat = useEased(num(V.lat), 0.14);
|
||||
const lon = useEased(num(V.lon), 0.14);
|
||||
const hdg = useEasedAngle(num(V.heading), 0.10);
|
||||
const trk = num(V.track);
|
||||
// arc map geometry: portrait DU-870 tube (identical to the PFD); ownship low,
|
||||
// ~120° forward arc filling the upper two-thirds, data blocks along the bottom.
|
||||
const W = 800, H = 940, cx = 400, cy = 740, R = 540; // compass radius
|
||||
const pxPerNm = R / rng;
|
||||
const project = (d, brg) => { // heading-up
|
||||
const rel = toRad(brg - hdg);
|
||||
return [cx + Math.sin(rel) * d * pxPerNm, cy - Math.cos(rel) * d * pxPerNm];
|
||||
};
|
||||
|
||||
// build route polyline from waypoints relative to ownship
|
||||
const wps = (fp.waypoints || []).map((w) => {
|
||||
if (!isFinite(w.lat) || !isFinite(w.lon)) return null;
|
||||
const g = geo(lat, lon, w.lat, w.lon);
|
||||
const [x, y] = project(g.dist, g.brg);
|
||||
return { ...w, x, y, dist: g.dist };
|
||||
}).filter(Boolean);
|
||||
const active = num(fp.activeLeg ?? 1);
|
||||
|
||||
// compass arc ticks
|
||||
const ticks = [];
|
||||
for (let i = -60; i <= 60; i += 5) {
|
||||
const a = toRad(i), x1 = cx + Math.sin(a) * R, y1 = cy - Math.cos(a) * R;
|
||||
const len = i % 30 === 0 ? 20 : i % 10 === 0 ? 14 : 8;
|
||||
const x2 = cx + Math.sin(a) * (R - len), y2 = cy - Math.cos(a) * (R - len);
|
||||
ticks.push(<line key={i} x1={x1} y1={y1} x2={x2} y2={y2} stroke="#cfd6dc" strokeWidth={i % 30 === 0 ? 1.8 : 1} />);
|
||||
if (i % 30 === 0) {
|
||||
const h = mod360(hdg + i), lx = cx + Math.sin(a) * (R - 36), ly = cy - Math.cos(a) * (R - 36);
|
||||
ticks.push(<text key={`l${i}`} x={lx} y={ly + 5} fontSize="16" fill="#e8edf1" textAnchor="middle">{String(Math.round(h / 10) % 36).padStart(2, '0')}</text>);
|
||||
}
|
||||
}
|
||||
|
||||
// coupled nav source (Nav Source Selector): FMS flight plan vs VOR1/VOR2.
|
||||
const cdiSrc = num(V.cdiSrc);
|
||||
const src = cdiSrc === 2 ? 'fms' : 'nav';
|
||||
const srcLabel = cdiSrc === 2 ? 'FMS1' : cdiSrc === 1 ? 'VOR2' : 'VOR1';
|
||||
const gs = Math.round(num(V.groundspeed) * 1.94384);
|
||||
const tas = Math.round(num(V.tas));
|
||||
const sat = Math.round(num(V.oat));
|
||||
// ETE to destination (last wp) at current GS
|
||||
const destDist = wps.length ? wps[wps.length - 1].dist : 0;
|
||||
const eteMin = gs > 20 ? destDist / gs * 60 : 0;
|
||||
const fmt = (s) => `${String(Math.floor(s / 60)).padStart(2, '0')}:${String(Math.floor(s % 60)).padStart(2, '0')}`;
|
||||
const now = new Date();
|
||||
|
||||
const SK = ({ label, on, onClick }) => <button className={`cit-sk ${on ? 'on' : ''}`} onClick={onClick}>{label}</button>;
|
||||
|
||||
return (
|
||||
<div className="cit-screen">
|
||||
<svg className="cit-mfd" viewBox="0 0 800 940" preserveAspectRatio="xMidYMid meet">
|
||||
<rect x={0} y={0} width={800} height={940} fill="#04070a" />
|
||||
<clipPath id="mfdclip"><rect x={0} y={70} width={800} height={770} /></clipPath>
|
||||
|
||||
{/* heading box (#2,#20) + FMS/NAV source (#4) */}
|
||||
<text x={24} y={30} fontSize="14" fill="#19c3e0">HDG</text>
|
||||
<text x={24} y={52} fontSize="22" fill="#d24bd2">{String(Math.round(mod360(num(V.apHdgBug)))).padStart(3, '0')}</text>
|
||||
<rect x={320} y={12} width={160} height={30} fill="none" stroke="#2a3138" />
|
||||
<text x={400} y={33} fontSize="18" fill="#13e000" textAnchor="middle">{String(Math.round(mod360(hdg))).padStart(3, '0')}°</text>
|
||||
<text x={776} y={30} fontSize="16" fill={src === 'fms' ? '#d24bd2' : '#13e000'} textAnchor="end">{srcLabel}</text>
|
||||
|
||||
<g clipPath="url(#mfdclip)">
|
||||
{/* compass arc (#3) */}
|
||||
{ticks}
|
||||
{/* range arc (#7) at mid range */}
|
||||
<path d={`M ${cx + Math.sin(toRad(-60)) * R / 2} ${cy - Math.cos(toRad(-60)) * R / 2} A ${R / 2} ${R / 2} 0 0 1 ${cx + Math.sin(toRad(60)) * R / 2} ${cy - Math.cos(toRad(60)) * R / 2}`} fill="none" stroke="#3a4148" strokeDasharray="3 6" />
|
||||
<text x={cx + R / 2 - 8} y={cy - R / 2} fontSize="13" fill="#9aa6ad">{rng / 2}</text>
|
||||
|
||||
{/* NEXRAD weather (#16 WX) */}
|
||||
{ov.terrain && (V.wxCells || []).map((c, i) => {
|
||||
const g = geo(lat, lon, c.lat, c.lon); const [x, y] = project(g.dist, g.brg);
|
||||
return <circle key={i} cx={x} cy={y} r={c.r * pxPerNm} fill={['#0a5', '#aa0', '#a00'][c.lvl - 1]} opacity="0.4" />;
|
||||
})}
|
||||
|
||||
{/* flight-plan route (#5 white future, #6 magenta active) */}
|
||||
{wps.length > 1 && wps.map((w, i) => i === 0 ? null : (
|
||||
<line key={`leg${i}`} x1={wps[i - 1].x} y1={wps[i - 1].y} x2={w.x} y2={w.y}
|
||||
stroke={i === active ? '#d24bd2' : '#e8edf1'} strokeWidth={i === active ? 3 : 2} />
|
||||
))}
|
||||
{wps.map((w, i) => (
|
||||
<g key={`wp${i}`}>
|
||||
{(w.type === 'APT') ? (ov.apts && <circle cx={w.x} cy={w.y} r="6" fill="none" stroke="#13e000" strokeWidth="2" />)
|
||||
: (ov.vor && <polygon points={`${w.x},${w.y - 6} ${w.x + 6},${w.y} ${w.x},${w.y + 6} ${w.x - 6},${w.y}`} fill="none" stroke="#13e000" strokeWidth="1.6" />)}
|
||||
<text x={w.x + 9} y={w.y + 4} fontSize="12" fill="#e8edf1">{w.id}</text>
|
||||
</g>
|
||||
))}
|
||||
|
||||
{/* TCAS traffic (#13 TRAFFIC) */}
|
||||
{ov.traffic && (V.traffic || []).map((t, i) => {
|
||||
const g = geo(lat, lon, t.lat, t.lon); const [x, y] = project(g.dist, g.brg);
|
||||
const col = t.thr === 2 ? '#ff3b30' : t.thr === 1 ? '#ffb000' : '#19c3e0';
|
||||
return <g key={i}><polygon points={`${x},${y - 7} ${x + 7},${y} ${x},${y + 7} ${x - 7},${y}`} fill={col} /><text x={x + 10} y={y - 6} fontSize="10" fill={col}>{t.relAlt > 0 ? '+' : ''}{t.relAlt}</text></g>;
|
||||
})}
|
||||
|
||||
{/* ownship (#17) */}
|
||||
<g transform={`translate(${cx} ${cy})`}>
|
||||
<polygon points="0,-14 9,12 0,5 -9,12" fill="#fff" stroke="#000" strokeWidth="0.8" />
|
||||
</g>
|
||||
{/* heading bug on arc (#1) */}
|
||||
{(() => { const rel = mod360(num(V.apHdgBug) - hdg); const a = toRad(rel > 180 ? rel - 360 : rel); if (Math.abs(rel > 180 ? rel - 360 : rel) > 60) return null; const x = cx + Math.sin(a) * R, y = cy - Math.cos(a) * R; return <polygon points={`${x},${y} ${x - 7},${y - 12} ${x + 7},${y - 12}`} fill="#d24bd2" />; })()}
|
||||
</g>
|
||||
|
||||
{/* data group (#8) bottom-right */}
|
||||
<g transform="translate(600 800)">
|
||||
<rect x={0} y={0} width={184} height={120} fill="#070b0f" stroke="#2a3138" />
|
||||
<text x={92} y={20} fontSize="12" fill="#9aa6ad" textAnchor="middle">NM {rng}</text>
|
||||
<text x={10} y={44} fontSize="13" fill="#9aa6ad">ETE</text><text x={174} y={44} fontSize="14" fill="#13e000" textAnchor="end">{eteMin > 0 ? fmt(eteMin * 60) : '– –'}</text>
|
||||
<text x={10} y={66} fontSize="13" fill="#9aa6ad">SAT</text><text x={174} y={66} fontSize="14" fill="#13e000" textAnchor="end">{sat}°C</text>
|
||||
<text x={10} y={88} fontSize="13" fill="#9aa6ad">TAS</text><text x={174} y={88} fontSize="14" fill="#13e000" textAnchor="end">{tas}</text>
|
||||
<text x={10} y={110} fontSize="13" fill="#9aa6ad">GSPD</text><text x={174} y={110} fontSize="14" fill="#13e000" textAnchor="end">{gs}</text>
|
||||
</g>
|
||||
{/* EICAS SYS subset (#11) — a sub-set of the dedicated EICAS display */}
|
||||
{setup === 'eicas' && (() => {
|
||||
const a = (x, i) => (Array.isArray(x) ? num(x[i]) : num(x));
|
||||
const rows = eicasSub === 'elec' ? [['DC VOLTS', `${a(V.volts, 0).toFixed(0)} / ${a(V.volts, 1).toFixed(0)}`], ['DC AMPS', `${Math.round(a(V.genAmps, 0))} / ${Math.round(a(V.genAmps, 1) || a(V.genAmps, 0))}`], ['BATT', `${a(V.battVolt, 0).toFixed(1)}V`]]
|
||||
: eicasSub === 'apu' ? [['APU RPM', '0%'], ['APU EGT', '— °C'], ['BLEED', 'OFF']]
|
||||
: eicasSub === 'eng' ? [['N1 L/R', `${a(V.n1, 0).toFixed(1)} / ${a(V.n1, 1).toFixed(1)}`], ['N2 L/R', `${a(V.n2, 0).toFixed(0)} / ${a(V.n2, 1).toFixed(0)}`], ['ITT L/R', `${Math.round(a(V.itt, 0))} / ${Math.round(a(V.itt, 1))}`]]
|
||||
: [['FUEL QTY', `${Math.round((a(V.fuelQty, 0) + a(V.fuelQty, 1)) * 2.20462)} LB`], ['FLOW L/R', `${Math.round(a(V.fuelFlow, 0) * 7936.6)} / ${Math.round(a(V.fuelFlow, 1) * 7936.6)}`], ['HYD A/B', `${Math.round(a(V.hydPress, 0))} / ${Math.round(a(V.hydPress, 1))}`]];
|
||||
return (
|
||||
<g transform="translate(250 240)">
|
||||
<rect x={0} y={0} width={300} height={170} fill="#070b0f" stroke="#19c3e0" />
|
||||
<text x={150} y={26} fontSize="15" fill="#19c3e0" textAnchor="middle">EICAS · {eicasSub.toUpperCase()}</text>
|
||||
{rows.map(([k, v], i) => (
|
||||
<g key={k} transform={`translate(0 ${56 + i * 32})`}>
|
||||
<text x={18} y={0} fontSize="14" fill="#cfd6dc">{k}</text>
|
||||
<text x={282} y={0} fontSize="14" fill="#13e000" textAnchor="end">{v}</text>
|
||||
</g>
|
||||
))}
|
||||
</g>
|
||||
);
|
||||
})()}
|
||||
{/* V-SPEEDS reference card (#10) — Citation X operating speeds, manual p80 */}
|
||||
{vspd && (
|
||||
<g transform="translate(270 230)">
|
||||
<rect x={0} y={0} width={260} height={300} fill="#070b0f" stroke="#19c3e0" />
|
||||
<text x={130} y={26} fontSize="16" fill="#19c3e0" textAnchor="middle">V-SPEEDS · CITATION X</text>
|
||||
{[['Vr (rotate)', '145'], ['Vfe (flaps)', '180'], ['Vmo SL-8000', '270'], ['Vmo >8000', '350'],
|
||||
['Mmo', '0.935'], ['Vle/Vlo gear', '210'], ['Vref landing', '132'], ['Vso stall (ldg)', '115'],
|
||||
['Vs1 stall (clean)', '136']].map(([k, v], i) => (
|
||||
<g key={k} transform={`translate(0 ${52 + i * 26})`}>
|
||||
<text x={16} y={0} fontSize="14" fill="#cfd6dc">{k}</text>
|
||||
<text x={244} y={0} fontSize="14" fill="#13e000" textAnchor="end">{v}</text>
|
||||
</g>
|
||||
))}
|
||||
</g>
|
||||
)}
|
||||
{/* clock / ET + WX status (#12,#16) bottom-left */}
|
||||
<g transform="translate(16 800)">
|
||||
<rect x={0} y={0} width={150} height={120} fill="#070b0f" stroke="#2a3138" />
|
||||
<text x={75} y={20} fontSize="13" fill="#13e000" textAnchor="middle">{now.toTimeString().slice(0, 8)}</text>
|
||||
<text x={75} y={38} fontSize="11" fill="#9aa6ad" textAnchor="middle">CLOCK</text>
|
||||
<text x={75} y={62} fontSize="15" fill="#13e000" textAnchor="middle">ET {fmt(et)}</text>
|
||||
<text x={10} y={92} fontSize="12" fill={ov.terrain ? '#13e000' : '#5a6168'}>WX</text>
|
||||
<text x={10} y={110} fontSize="11" fill="#9aa6ad">T0.0 G100%</text>
|
||||
</g>
|
||||
</svg>
|
||||
|
||||
<div className="cit-bezel cit-mfd-sk">
|
||||
{setup === 'mfd' ? (
|
||||
<>
|
||||
<SK label="TRAFFIC" on={ov.traffic} onClick={() => setOv((o) => ({ ...o, traffic: !o.traffic }))} />
|
||||
<SK label="TERRAIN" on={ov.terrain} onClick={() => setOv((o) => ({ ...o, terrain: !o.terrain }))} />
|
||||
<SK label="APTS" on={ov.apts} onClick={() => setOv((o) => ({ ...o, apts: !o.apts }))} />
|
||||
<SK label="VOR" on={ov.vor} onClick={() => setOv((o) => ({ ...o, vor: !o.vor }))} />
|
||||
<SK label="RTN" onClick={() => setSetup(null)} />
|
||||
</>
|
||||
) : setup === 'pfd' ? (
|
||||
<>
|
||||
<SK label="IN" on={baroUnit === 'in'} onClick={() => setBaroUnit('in')} />
|
||||
<SK label="HPA" on={baroUnit === 'hpa'} onClick={() => setBaroUnit('hpa')} />
|
||||
<SK label="RTN" onClick={() => setSetup(null)} />
|
||||
</>
|
||||
) : setup === 'eicas' ? (
|
||||
<>
|
||||
<SK label="FUEL/HYD" on={eicasSub === 'fuel'} onClick={() => setEicasSub('fuel')} />
|
||||
<SK label="ELEC" on={eicasSub === 'elec'} onClick={() => setEicasSub('elec')} />
|
||||
<SK label="APU" on={eicasSub === 'apu'} onClick={() => setEicasSub('apu')} />
|
||||
<SK label="ENG" on={eicasSub === 'eng'} onClick={() => setEicasSub('eng')} />
|
||||
<SK label="RTN" onClick={() => setSetup(null)} />
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<button className="cit-sk" onClick={() => setSetup('pfd')}>PFD SETUP</button>
|
||||
<button className="cit-sk" onClick={() => setSetup('mfd')}>MFD SETUP</button>
|
||||
<button className="cit-sk" onClick={() => { etRun.current = !etRun.current; }}>ET/FT</button>
|
||||
<button className="cit-sk" onClick={() => setSetup('eicas')}>EICAS SYS</button>
|
||||
<button className={`cit-sk ${vspd ? 'on' : ''}`} onClick={() => setVspd((v) => !v)}>V SPEEDS</button>
|
||||
<div className="cit-bz-group">
|
||||
<span className="cit-bz-lbl">RNG</span>
|
||||
<button className="cit-bz-knob" onClick={() => setRng((r) => RNGS[Math.max(0, RNGS.indexOf(r) - 1)])}>−</button>
|
||||
<span className="cit-bz-val">{rng}</span>
|
||||
<button className="cit-bz-knob" onClick={() => setRng((r) => RNGS[Math.min(RNGS.length - 1, RNGS.indexOf(r) + 1)])}>+</button>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,424 @@
|
||||
import React, { useRef } from 'react';
|
||||
import { num } from '../../api/useXplane.js';
|
||||
import { useEased, useEasedAngle } from '../../api/ease.js';
|
||||
|
||||
// ============================================================================
|
||||
// Cessna Citation X — Primary Flight Display (Honeywell Primus 2000).
|
||||
// Built line-for-line against the X-Plane Citation X manual (pages 30-31):
|
||||
// 1 Attitude · 2 Airspeed scale · 3 Airspeed trend · 4 Heading bug
|
||||
// 5 Desired course (CRS) · 6 Secondary NAV (bearing pointers) · 7 Desired hdg
|
||||
// 8 Minimums · 9 RA/BARO · 10 HSI · 11 STD · 12 BARO SET · 13/14 VSI
|
||||
// 15 CDI · 16 DME · 17 Altimeter setting · 18 Radar altimeter
|
||||
// 19 FD lateral bar · 20 Altitude trend · 21 Altimeter scale · 22 FD vertical bar
|
||||
// All values are live X-Plane datarefs streamed by the bridge (no Lua needed).
|
||||
// ============================================================================
|
||||
|
||||
const PXDEG = 7; // pitch ladder px per degree
|
||||
const clamp = (v, a, b) => Math.max(a, Math.min(b, v));
|
||||
const mod360 = (d) => ((d % 360) + 360) % 360;
|
||||
const hz2mhz = (hz) => (num(hz) / 100).toFixed(2);
|
||||
|
||||
// ── airspeed tape ───────────────────────────────────────────────────────────
|
||||
// Citation X operating speeds (manual p80): Vso 115 · Vs1 136 · Vfe 180 ·
|
||||
// Vmo 270 (SL-8000') / 350 (above) · Mmo 0.935.
|
||||
const VSO = 115, VS1 = 136, VFE = 180;
|
||||
function SpeedTape({ ias, mach, bug, alt, trendKt }) {
|
||||
const H = 560, mid = H / 2, pxkt = 3.4; // 3.4 px per knot
|
||||
const y = (s) => mid + (ias - s) * pxkt;
|
||||
const vmo = alt > 8000 ? 350 : 270;
|
||||
const top = ias + mid / pxkt, marks = [];
|
||||
for (let s = Math.ceil((ias - mid / pxkt) / 10) * 10; s <= top; s += 10) {
|
||||
if (s < 0) continue;
|
||||
marks.push(
|
||||
<g key={s}>
|
||||
<line x1={70} y1={y(s)} x2={s % 20 === 0 ? 58 : 64} y2={y(s)} stroke="#cfd6dc" strokeWidth="1.4" />
|
||||
{s % 20 === 0 && <text x={54} y={y(s) + 4} fontSize="17" fill="#e8edf1" textAnchor="end">{s}</text>}
|
||||
</g>,
|
||||
);
|
||||
}
|
||||
return (
|
||||
<g>
|
||||
<rect x={0} y={0} width={74} height={H} fill="#0c1116" opacity="0.82" />
|
||||
<clipPath id="spdclip"><rect x={0} y={0} width={74} height={H} /></clipPath>
|
||||
<g clipPath="url(#spdclip)">
|
||||
{/* low-speed awareness: red below Vso, amber Vso→Vs1 */}
|
||||
<rect x={0} y={y(VSO)} width={8} height={Math.max(0, H - y(VSO))} fill="#c0392b" />
|
||||
<rect x={0} y={y(VS1)} width={8} height={Math.max(0, y(VSO) - y(VS1))} fill="#ffb000" />
|
||||
{/* Vmo/Mmo barber pole: overspeed band from the top down to the Vmo line */}
|
||||
<rect x={0} y={0} width={8} height={clamp(y(vmo), 0, H)} fill="url(#barber)" />
|
||||
{/* Vfe flap-limit marker */}
|
||||
<line x1={0} y1={y(VFE)} x2={12} y2={y(VFE)} stroke="#fff" strokeWidth="3" />
|
||||
{marks}
|
||||
{/* airspeed trend vector (#3): magenta line from the index up/down */}
|
||||
{Math.abs(trendKt) > 1 && (
|
||||
<g>
|
||||
<line x1={71} y1={mid} x2={71} y2={mid - trendKt * pxkt} stroke="#d24bd2" strokeWidth="3" />
|
||||
<polygon points={`71,${mid - trendKt * pxkt} 67,${mid - trendKt * pxkt + (trendKt > 0 ? 8 : -8)} 75,${mid - trendKt * pxkt + (trendKt > 0 ? 8 : -8)}`} fill="#d24bd2" />
|
||||
</g>
|
||||
)}
|
||||
</g>
|
||||
<defs>
|
||||
<pattern id="barber" width="8" height="8" patternUnits="userSpaceOnUse" patternTransform="rotate(45)">
|
||||
<rect width="8" height="8" fill="#fff" /><rect width="4" height="8" fill="#c0392b" />
|
||||
</pattern>
|
||||
</defs>
|
||||
{/* selected-speed bug (magenta) */}
|
||||
{bug > 20 && <polygon points={`74,${clamp(y(bug), 6, H - 6)} 64,${clamp(y(bug), 6, H - 6) - 7} 64,${clamp(y(bug), 6, H - 6) + 7}`} fill="#d24bd2" />}
|
||||
{/* current readout box */}
|
||||
<polygon points={`0,${mid - 20} 60,${mid - 20} 74,${mid} 60,${mid + 20} 0,${mid + 20}`} fill="#000" stroke="#cfd6dc" strokeWidth="1.4" />
|
||||
<text x={50} y={mid + 8} fontSize="26" fill="#fff" textAnchor="end" fontWeight="700">{Math.round(ias)}</text>
|
||||
{mach >= 0.4 && <text x={40} y={H - 6} fontSize="16" fill="#13e000" textAnchor="middle">M{mach.toFixed(2).slice(1)}</text>}
|
||||
</g>
|
||||
);
|
||||
}
|
||||
|
||||
// ── AOA index (manual p22): normalised 0 (zero-lift) … 1.0 (stall); the
|
||||
// pilot keeps AOA below 0.6 (30% margin). alpha≈14° ≈ stall.
|
||||
function AoaIndex({ alpha }) {
|
||||
const n = clamp(alpha / 14, 0, 1.05);
|
||||
const H = 120, y = (v) => H - v / 1.05 * H;
|
||||
return (
|
||||
<g>
|
||||
<text x={0} y={-6} fontSize="11" fill="#9aa6ad" textAnchor="middle">AOA</text>
|
||||
<rect x={-7} y={0} width={14} height={H} fill="#0c1116" stroke="#2a3138" />
|
||||
<rect x={-7} y={0} width={14} height={y(0.85)} fill="#c0392b" opacity="0.55" />
|
||||
<rect x={-7} y={y(0.85)} width={14} height={y(0.6) - y(0.85)} fill="#ffb000" opacity="0.5" />
|
||||
<rect x={-7} y={y(0.6)} width={14} height={H - y(0.6)} fill="#13a800" opacity="0.4" />
|
||||
<polygon points={`8,${y(n)} 18,${y(n) - 6} 18,${y(n) + 6}`} fill="#fff" />
|
||||
</g>
|
||||
);
|
||||
}
|
||||
|
||||
// ── altitude tape + VSI ───────────────────────────────────────────────────────
|
||||
function AltTape({ alt, bug, vs, baro, std, baroHpa, minOn, minFt, raBaro }) {
|
||||
const H = 560, mid = H / 2, pxft = 0.32; // px per foot
|
||||
const top = alt + mid / pxft, marks = [];
|
||||
for (let s = Math.ceil((alt - mid / pxft) / 100) * 100; s <= top; s += 100) {
|
||||
const y = mid + (alt - s) * pxft;
|
||||
marks.push(
|
||||
<g key={s}>
|
||||
<line x1={6} y1={y} x2={s % 500 === 0 ? 22 : 16} y2={y} stroke="#cfd6dc" strokeWidth="1.4" />
|
||||
{s % 200 === 0 && <text x={26} y={y + 4} fontSize="16" fill="#e8edf1">{s}</text>}
|
||||
</g>,
|
||||
);
|
||||
}
|
||||
const baroTxt = std ? 'STD' : baroHpa ? `${Math.round(num(baro) * 33.8639)}` : num(baro).toFixed(2);
|
||||
const minY = clamp(mid + (alt - minFt) * pxft, 6, H - 6);
|
||||
return (
|
||||
<g>
|
||||
<rect x={0} y={0} width={120} height={H} fill="#0c1116" opacity="0.82" />
|
||||
<clipPath id="altclip"><rect x={0} y={0} width={120} height={H} /></clipPath>
|
||||
<g clipPath="url(#altclip)">
|
||||
{marks}
|
||||
{/* altitude trend (green, 6 s projection of VSI) */}
|
||||
<rect x={2} y={Math.min(mid, mid - vs / 10 * pxft)} width={4} height={Math.abs(vs / 10 * pxft)} fill="#13e000" />
|
||||
{/* minimums bug (cyan) */}
|
||||
{minOn && <polygon points={`6,${minY} 22,${minY - 7} 22,${minY + 7}`} fill="#19c3e0" />}
|
||||
</g>
|
||||
{/* selected-altitude bug (magenta) */}
|
||||
<polygon points={`0,${clamp(mid + (alt - bug) * pxft, 6, H - 6) - 8} 14,${clamp(mid + (alt - bug) * pxft, 6, H - 6) - 8} 14,${clamp(mid + (alt - bug) * pxft, 6, H - 6) + 8} 0,${clamp(mid + (alt - bug) * pxft, 6, H - 6) + 8}`} fill="#d24bd2" />
|
||||
{/* current readout box */}
|
||||
<polygon points={`120,${mid - 20} 30,${mid - 20} 16,${mid} 30,${mid + 20} 120,${mid + 20}`} fill="#000" stroke="#cfd6dc" strokeWidth="1.4" />
|
||||
<text x={112} y={mid + 8} fontSize="25" fill="#fff" textAnchor="end" fontWeight="700">{Math.round(alt)}</text>
|
||||
{/* baro setting */}
|
||||
<text x={60} y={H + 26} fontSize="17" fill={std ? '#13e000' : '#19c3e0'} textAnchor="middle">{std ? 'STD' : `${baroTxt}${baroHpa ? '' : ''}`}</text>
|
||||
{minOn && <text x={60} y={H + 46} fontSize="13" fill="#19c3e0" textAnchor="middle">{raBaro ? 'RA' : 'BARO'} {Math.round(minFt)}</text>}
|
||||
</g>
|
||||
);
|
||||
}
|
||||
|
||||
function VSI({ vs }) {
|
||||
const H = 480, mid = H / 2;
|
||||
// non-linear-ish: ±2000 fpm across the scale
|
||||
const y = (fpm) => mid - clamp(fpm, -2500, 2500) / 2500 * (H / 2 - 10);
|
||||
const ticks = [0, 500, 1000, 2000];
|
||||
return (
|
||||
<g>
|
||||
<rect x={0} y={0} width={48} height={H} fill="#0c1116" opacity="0.7" rx="6" />
|
||||
{ticks.map((t) => (
|
||||
<g key={t}>
|
||||
<line x1={0} y1={y(t)} x2={t % 1000 === 0 ? 16 : 10} y2={y(t)} stroke="#9aa6ad" strokeWidth="1.2" />
|
||||
<line x1={0} y1={y(-t)} x2={t % 1000 === 0 ? 16 : 10} y2={y(-t)} stroke="#9aa6ad" strokeWidth="1.2" />
|
||||
{t > 0 && <text x={20} y={y(t) + 4} fontSize="11" fill="#9aa6ad">{t / 1000}</text>}
|
||||
{t > 0 && <text x={20} y={y(-t) + 4} fontSize="11" fill="#9aa6ad">{t / 1000}</text>}
|
||||
</g>
|
||||
))}
|
||||
<line x1={0} y1={mid} x2={48} y2={y(vs)} stroke="#13e000" strokeWidth="3" />
|
||||
{Math.abs(vs) > 100 && <text x={24} y={vs > 0 ? 14 : H - 4} fontSize="14" fill="#13e000" textAnchor="middle" fontWeight="700">{Math.round(vs / 50) * 50}</text>}
|
||||
</g>
|
||||
);
|
||||
}
|
||||
|
||||
// ── attitude ball ─────────────────────────────────────────────────────────────
|
||||
function Attitude({ pitch, roll, slip, fdP, fdR, fdOn }) {
|
||||
const R = 196, cx = 0, cy = 0;
|
||||
const ladder = [];
|
||||
for (let p = -90; p <= 90; p += 10) {
|
||||
if (p === 0) continue;
|
||||
const y = p * PXDEG;
|
||||
const w = p % 20 === 0 ? 70 : 36;
|
||||
ladder.push(
|
||||
<g key={p}>
|
||||
<line x1={-w / 2} y1={-y} x2={w / 2} y2={-y} stroke="#fff" strokeWidth="2" />
|
||||
{p % 20 === 0 && <>
|
||||
<text x={-w / 2 - 8} y={-y + 5} fontSize="14" fill="#fff" textAnchor="end">{Math.abs(p)}</text>
|
||||
<text x={w / 2 + 8} y={-y + 5} fontSize="14" fill="#fff">{Math.abs(p)}</text>
|
||||
</>}
|
||||
</g>,
|
||||
);
|
||||
}
|
||||
// bank scale arc marks (top)
|
||||
const bankMarks = [-60, -45, -30, -20, -10, 0, 10, 20, 30, 45, 60].map((b) => {
|
||||
const a = (b - 90) * Math.PI / 180, r1 = R, r2 = b % 30 === 0 || b === 0 ? R - 16 : R - 10;
|
||||
return <line key={b} x1={Math.cos(a) * r1} y1={Math.sin(a) * r1} x2={Math.cos(a) * r2} y2={Math.sin(a) * r2} stroke="#fff" strokeWidth={b === 0 ? 0 : 1.6} />;
|
||||
});
|
||||
return (
|
||||
<g>
|
||||
<clipPath id="attclip"><circle cx={cx} cy={cy} r={R} /></clipPath>
|
||||
<g clipPath="url(#attclip)">
|
||||
{/* sky / ground, rotated by roll then pitched */}
|
||||
<g transform={`rotate(${-roll})`}>
|
||||
<g transform={`translate(0 ${pitch * PXDEG})`}>
|
||||
<rect x={-600} y={-1200} width={1200} height={1200} fill="#3a86c8" />
|
||||
<rect x={-600} y={0} width={1200} height={1200} fill="#6b4a2b" />
|
||||
<line x1={-600} y1={0} x2={600} y2={0} stroke="#fff" strokeWidth="2.5" />
|
||||
{ladder}
|
||||
</g>
|
||||
</g>
|
||||
</g>
|
||||
{/* fixed bank pointer + arc */}
|
||||
<g transform={`rotate(${-roll})`}>
|
||||
<polygon points={`0,${-R + 2} -10,${-R + 20} 10,${-R + 20}`} fill="#ffd400" />
|
||||
</g>
|
||||
{bankMarks}
|
||||
<polygon points={`0,${-R - 2} -9,${-R - 18} 9,${-R - 18}`} fill="#fff" />
|
||||
{/* slip/skid trapezoid below the bank pointer */}
|
||||
<g transform={`rotate(${-roll})`}>
|
||||
<rect x={-14 + clamp(slip, -1, 1) * 26} y={-R + 22} width={28} height={7} fill="#ffd400" stroke="#000" strokeWidth="0.6" />
|
||||
</g>
|
||||
{/* fixed aircraft reference (yellow) */}
|
||||
<g>
|
||||
<rect x={-2.5} y={-2.5} width={5} height={5} fill="#ffd400" />
|
||||
<line x1={-90} y1={0} x2={-30} y2={0} stroke="#ffd400" strokeWidth="4" />
|
||||
<line x1={30} y1={0} x2={90} y2={0} stroke="#ffd400" strokeWidth="4" />
|
||||
<line x1={-30} y1={0} x2={-30} y2={12} stroke="#ffd400" strokeWidth="4" />
|
||||
<line x1={30} y1={0} x2={30} y2={12} stroke="#ffd400" strokeWidth="4" />
|
||||
</g>
|
||||
{/* flight-director command bars (magenta V-bars) — #19 lateral / #22 vertical */}
|
||||
{fdOn && (
|
||||
<g transform={`translate(0 ${clamp(-fdP * PXDEG, -70, 70)}) rotate(${clamp(fdR, -30, 30)})`}>
|
||||
<polyline points="-70,16 0,2 70,16" fill="none" stroke="#d24bd2" strokeWidth="4" />
|
||||
</g>
|
||||
)}
|
||||
</g>
|
||||
);
|
||||
}
|
||||
|
||||
// ── HSI (rotating compass with CDI + bearing pointers) ─────────────────────────
|
||||
function HSI({ hdg, trk, crs, hdgBug, cdi, toFrom, brg1, brg2, srcLabel, srcColor }) {
|
||||
const R = 150;
|
||||
const card = [];
|
||||
for (let d = 0; d < 360; d += 5) {
|
||||
const a = (d - hdg - 90) * Math.PI / 180, r2 = d % 10 === 0 ? R - 14 : R - 8;
|
||||
card.push(<line key={d} x1={Math.cos(a) * R} y1={Math.sin(a) * R} x2={Math.cos(a) * r2} y2={Math.sin(a) * r2} stroke="#cfd6dc" strokeWidth={d % 30 === 0 ? 1.8 : 1} />);
|
||||
if (d % 30 === 0) {
|
||||
const rt = R - 30, lbl = d === 0 ? 'N' : d === 90 ? 'E' : d === 180 ? 'S' : d === 270 ? 'W' : d / 10;
|
||||
card.push(<text key={`t${d}`} x={Math.cos(a) * rt} y={Math.sin(a) * rt + 5} fontSize="14" fill="#e8edf1" textAnchor="middle">{lbl}</text>);
|
||||
}
|
||||
}
|
||||
const ptr = (deg, color, dbl) => {
|
||||
const a = (deg - hdg) * Math.PI / 180; // 0 = up
|
||||
const x = Math.sin(a), y = -Math.cos(a);
|
||||
return (
|
||||
<g stroke={color} strokeWidth="2.5" fill="none">
|
||||
<line x1={x * (R - 16)} y1={y * (R - 16)} x2={x * 40} y2={y * 40} />
|
||||
{/* arrow head */}
|
||||
<polygon points={`${x * (R - 16)},${y * (R - 16)} ${x * (R - 34) - y * 8},${y * (R - 34) + x * 8} ${x * (R - 34) + y * 8},${y * (R - 34) - x * 8}`} fill={color} />
|
||||
{dbl && <line x1={x * -40} y1={y * -40} x2={x * -(R - 16)} y2={y * -(R - 16)} />}
|
||||
</g>
|
||||
);
|
||||
};
|
||||
return (
|
||||
<g>
|
||||
<circle cx={0} cy={0} r={R} fill="#0a0e12" stroke="#2a3138" strokeWidth="1.5" />
|
||||
<g>{card}</g>
|
||||
{/* heading bug (magenta) */}
|
||||
<g transform={`rotate(${mod360(hdgBug - hdg)})`}><polygon points={`0,${-R} -9,${-R + 14} 9,${-R + 14}`} fill="#d24bd2" /></g>
|
||||
{/* course pointer + CDI deviation (#5 + #15) — FMS magenta / VOR green */}
|
||||
<g transform={`rotate(${mod360(crs - hdg)})`}>
|
||||
<line x1={0} y1={-R + 18} x2={0} y2={-50} stroke={srcColor} strokeWidth="3" />
|
||||
<polygon points={`0,${-R + 6} -8,${-R + 22} 8,${-R + 22}`} fill={srcColor} />
|
||||
<line x1={0} y1={50} x2={0} y2={R - 18} stroke={srcColor} strokeWidth="3" />
|
||||
{/* CDI bar */}
|
||||
<line x1={clamp(cdi, -2, 2) * 30} y1={-46} x2={clamp(cdi, -2, 2) * 30} y2={46} stroke={srcColor} strokeWidth="3.5" />
|
||||
{[-2, -1, 1, 2].map((d) => <circle key={d} cx={d * 30} cy={0} r="3" fill="none" stroke="#9aa6ad" strokeWidth="1.2" />)}
|
||||
{toFrom !== 0 && <polygon points={toFrom > 0 ? '0,-14 -8,2 8,2' : '0,14 -8,-2 8,-2'} fill={srcColor} />}
|
||||
</g>
|
||||
{/* bearing pointers — #6 secondary NAV (cyan circle = BRG1, white diamond = BRG2) */}
|
||||
{brg1 != null && ptr(brg1, '#19c3e0', false)}
|
||||
{brg2 != null && ptr(brg2, '#cfd6dc', true)}
|
||||
{/* fixed lubber line + aircraft */}
|
||||
<polygon points={`0,${-R - 2} -8,${-R - 16} 8,${-R - 16}`} fill="#fff" />
|
||||
<text x={0} y={-R - 22} fontSize="13" fill="#fff" textAnchor="middle" fontWeight="700">{String(Math.round(mod360(hdg))).padStart(3, '0')}</text>
|
||||
<text x={0} y={6} fontSize="12" fill={srcColor} textAnchor="middle">{srcLabel}</text>
|
||||
</g>
|
||||
);
|
||||
}
|
||||
|
||||
export default function CitPFD({ xp, navSrc }) {
|
||||
const V = xp.values || {};
|
||||
const bsrc = navSrc || { brg1: 'VOR1', brg2: 'VOR2' };
|
||||
const [std, setStd] = React.useState(false);
|
||||
const [raBaro, setRaBaro] = React.useState(false); // #9 RA/BARO minimums source
|
||||
const [min, setMin] = React.useState({ on: false, ft: 200 });
|
||||
const trend = useRef({ ias: 0, t: 0 });
|
||||
|
||||
// Smooth the moving symbology toward the live datarefs (frame-rate-independent
|
||||
// easing) — the same rAF glide the G1000 uses, so a 10-20 Hz stream renders as
|
||||
// fluid 60 fps motion instead of stepping.
|
||||
const ias = useEased(num(V.airspeed), 0.10);
|
||||
const alt = useEased(num(V.altitude), 0.12);
|
||||
const vs = useEased(num(V.vspeed), 0.18);
|
||||
const pitch = useEased(num(V.pitch), 0.07);
|
||||
const roll = useEased(num(V.roll), 0.07);
|
||||
const slip = useEased(num(V.slip), 0.12);
|
||||
const hdg = useEasedAngle(num(V.heading), 0.08);
|
||||
const crs = useEasedAngle(num(V.obsCrs), 0.10);
|
||||
const hdgBug = useEasedAngle(num(V.apHdgBug), 0.10);
|
||||
const cdi = useEased(num(V.hsiDef), 0.12);
|
||||
const mach = useEased(num(V.mach), 0.2);
|
||||
const aoa = useEased(num(V.aoa), 0.12);
|
||||
const hdgRaw = num(V.heading);
|
||||
const vor1e = useEasedAngle(num(V.nav1Brg), 0.12);
|
||||
const vor2e = useEasedAngle(num(V.nav2Brg), 0.12);
|
||||
const adf1e = useEasedAngle(mod360(hdgRaw + num(V.adf1Brg)), 0.12); // ADF relative → mag bearing
|
||||
const adf2e = useEasedAngle(mod360(hdgRaw + num(V.adf2Brg)), 0.12);
|
||||
const gpsBrgE = useEasedAngle(num(V.gpsBearing), 0.12);
|
||||
const trk = num(V.track), toFrom = num(V.hsiToFrom);
|
||||
const baro = num(V.baro, 29.92);
|
||||
const radAlt = num(V.radioAlt, 99999);
|
||||
const fdOn = num(V.apMode) >= 1 || num(V.apEngaged) > 0;
|
||||
// bearing pointer source (Nav Source Selector): resolve each pointer to a
|
||||
// magnetic bearing or null (no station / OFF). Pointer 1 = cyan ◯, 2 = white ◇.
|
||||
const pickBrg = (sel) => {
|
||||
if (sel === 'VOR1') return (num(V.nav1Dme) > 0 || num(V.nav1Brg) > 0) ? vor1e : null;
|
||||
if (sel === 'VOR2') return (num(V.nav2Dme) > 0 || num(V.nav2Brg) > 0) ? vor2e : null;
|
||||
if (sel === 'ADF1') return num(V.adf1Brg) ? adf1e : null;
|
||||
if (sel === 'ADF2') return num(V.adf2Brg) ? adf2e : null;
|
||||
if (sel === 'FMS1' || sel === 'FMS') return num(V.gpsBearing) ? gpsBrgE : null;
|
||||
return null;
|
||||
};
|
||||
const brg1 = pickBrg(bsrc.brg1);
|
||||
const brg2 = pickBrg(bsrc.brg2);
|
||||
// FMA / AFCS mode annunciation (active = green, armed = white)
|
||||
const st = (k) => num(V[k]);
|
||||
const latM = st('aprStatus') ? ['LOC', st('aprStatus')] : (st('navStatus') || st('gpssStatus')) ? ['NAV', Math.max(st('navStatus'), st('gpssStatus'))]
|
||||
: st('bcStatus') ? ['BC', st('bcStatus')] : st('hdgStatus') ? ['HDG', st('hdgStatus')] : ['ROLL', 2];
|
||||
const vertM = st('gsStatus') ? ['GS', st('gsStatus')] : st('vnavStatus') ? ['VNAV', st('vnavStatus')]
|
||||
: st('flcStatus') ? ['FLC', st('flcStatus')] : st('vsStatus') ? ['VS', st('vsStatus')]
|
||||
: st('altStatus') ? ['ALT', st('altStatus')] : ['PITCH', 2];
|
||||
const apTxt = num(V.apEngaged) > 0 || num(V.apMode) >= 2 ? 'AP' : fdOn ? 'FD' : '';
|
||||
const fmaColor = (s) => (s >= 2 ? '#16e000' : '#fff');
|
||||
// Nav source (Nav Source Selector, manual p24): 0 VOR1 · 1 VOR2 · 2 FMS.
|
||||
// FMS course is magenta, VOR course is green (Honeywell convention).
|
||||
const cdiSrc = num(V.cdiSrc);
|
||||
const srcLabel = cdiSrc === 2 ? 'FMS1' : cdiSrc === 1 ? 'VOR2' : 'VOR1';
|
||||
const srcColor = cdiSrc === 2 ? '#d24bd2' : '#13e000';
|
||||
const cycleNav = () => xp.setDataref('cdiSrc', cdiSrc === 1 ? 0 : 1); // toggle VOR1↔VOR2
|
||||
const setFms = () => xp.setDataref('cdiSrc', 2);
|
||||
const dme = cdiSrc === 1 ? num(V.nav2Dme) : num(V.nav1Dme);
|
||||
// airspeed trend vector (#3): smoothed acceleration projected 10 s ahead
|
||||
const t = trend.current, nowS = (typeof performance !== 'undefined' ? performance.now() : Date.now()) / 1000;
|
||||
const dt = Math.min(0.5, Math.max(0.001, nowS - (t.t || nowS)));
|
||||
const rate = (ias - (t.ias != null ? t.ias : ias)) / dt; // kt/s
|
||||
t.s = (t.s || 0) * 0.92 + rate * 0.08; t.ias = ias; t.t = nowS;
|
||||
const trendKt = clamp(t.s * 10, -45, 45);
|
||||
|
||||
return (
|
||||
<div className="cit-screen">
|
||||
<svg className="cit-pfd" viewBox="0 0 800 940" preserveAspectRatio="xMidYMid meet">
|
||||
<rect x={0} y={0} width={800} height={940} fill="#05080b" />
|
||||
{/* FMA / AFCS mode annunciation bar (active green · armed white) */}
|
||||
<g transform="translate(204 8)">
|
||||
<rect x={0} y={0} width={392} height={26} fill="#0a0e12" stroke="#2a3138" />
|
||||
<line x1={130} y1={2} x2={130} y2={24} stroke="#2a3138" /><line x1={262} y1={2} x2={262} y2={24} stroke="#2a3138" />
|
||||
<text x={65} y={18} fontSize="14" fill={fmaColor(latM[1])} textAnchor="middle" fontWeight="700">{latM[0]}</text>
|
||||
<text x={196} y={18} fontSize="14" fill="#16e000" textAnchor="middle" fontWeight="700">{apTxt}</text>
|
||||
<text x={328} y={18} fontSize="14" fill={fmaColor(vertM[1])} textAnchor="middle" fontWeight="700">{vertM[0]}</text>
|
||||
</g>
|
||||
{/* attitude */}
|
||||
<g transform="translate(400 270)"><Attitude pitch={pitch} roll={roll} slip={slip} fdP={num(V.fdPitch)} fdR={num(V.fdRoll)} fdOn={fdOn} /></g>
|
||||
{/* speed tape (#2,#3) */}
|
||||
<g transform="translate(96 90)"><SpeedTape ias={ias} mach={mach} bug={num(V.apSpdBug)} alt={alt} trendKt={trendKt} /></g>
|
||||
<text x={120} y={78} fontSize="14" fill="#9aa6ad" textAnchor="middle">KIAS</text>
|
||||
{/* AOA index (#manual p22) */}
|
||||
<g transform="translate(48 600)"><AoaIndex alpha={aoa} /></g>
|
||||
{/* altitude tape (#20,#21) + baro (#12,#17) */}
|
||||
<g transform="translate(584 90)"><AltTape alt={alt} bug={num(V.apAltBug)} vs={vs} baro={baro} std={std} baroHpa={false} minOn={min.on} minFt={min.ft} raBaro={raBaro} /></g>
|
||||
{/* VSI (#13,#14) */}
|
||||
<g transform="translate(716 130)"><VSI vs={vs} /></g>
|
||||
{/* HSI (#10) */}
|
||||
<g transform="translate(400 690)"><HSI hdg={hdg} trk={trk} crs={crs} hdgBug={hdgBug} cdi={cdi} toFrom={toFrom} brg1={brg1} brg2={brg2} srcLabel={srcLabel} srcColor={srcColor} /></g>
|
||||
|
||||
{/* CRS / HDG digital (#5,#7) */}
|
||||
<g fontSize="15" fontWeight="700">
|
||||
<text x={20} y={560} fill="#19c3e0">CRS</text>
|
||||
<text x={20} y={580} fill="#19c3e0" fontSize="20">{String(Math.round(mod360(crs))).padStart(3, '0')}</text>
|
||||
<text x={20} y={642} fill="#d24bd2">HDG</text>
|
||||
<text x={20} y={662} fill="#d24bd2" fontSize="20">{String(Math.round(mod360(hdgBug))).padStart(3, '0')}</text>
|
||||
</g>
|
||||
{/* secondary NAV legend (#6) — reflects the Nav Source Selector */}
|
||||
<g fontSize="13">
|
||||
{bsrc.brg1 !== 'OFF' && <><circle cx={28} cy={726} r="6" fill="none" stroke="#19c3e0" strokeWidth="2" />
|
||||
<text x={42} y={731} fill="#19c3e0">{bsrc.brg1}</text></>}
|
||||
{bsrc.brg2 !== 'OFF' && <><rect x={22} y={744} width={12} height={12} fill="none" stroke="#cfd6dc" strokeWidth="2" transform="rotate(45 28 750)" />
|
||||
<text x={42} y={755} fill="#cfd6dc">{bsrc.brg2}</text></>}
|
||||
</g>
|
||||
{/* DME (#16) */}
|
||||
{dme > 0 && <text x={760} y={620} fontSize="16" fill="#13e000" textAnchor="end">{dme.toFixed(1)}NM</text>}
|
||||
{/* radar altimeter (#18) — only within 2500 ft AGL */}
|
||||
{radAlt < 2500 && <text x={400} y={420} fontSize="22" fill="#13e000" textAnchor="middle" fontWeight="700">{Math.round(radAlt)}</text>}
|
||||
{radAlt < 2500 && <text x={400} y={436} fontSize="11" fill="#9aa6ad" textAnchor="middle">RA</text>}
|
||||
{/* TCAS label */}
|
||||
<text x={690} y={840} fontSize="13" fill="#9aa6ad">TCAS</text>
|
||||
<text x={760} y={84} fontSize="13" fill="#9aa6ad" textAnchor="end">FT</text>
|
||||
</svg>
|
||||
|
||||
{/* bezel buttons — Nav Source Selector (p24, sits under the PFD), MINIMUMS
|
||||
rotary (#8), RA/BARO (#9), STD (#11), BARO SET (#12), CRS, HDG */}
|
||||
<div className="cit-bezel">
|
||||
<div className="cit-bz-group">
|
||||
<span className="cit-bz-lbl">NAV SRC</span>
|
||||
<button className={`cit-bz-btn ${cdiSrc !== 2 ? 'on' : ''}`} onClick={cycleNav}>{cdiSrc === 1 ? 'VOR2' : 'VOR1'}</button>
|
||||
<button className={`cit-bz-btn ${cdiSrc === 2 ? 'on' : ''}`} onClick={setFms}>FMS</button>
|
||||
</div>
|
||||
<div className="cit-bz-group">
|
||||
<span className="cit-bz-lbl">MINIMUMS</span>
|
||||
<button className="cit-bz-knob" onClick={() => setMin((m) => ({ ...m, on: true, ft: m.ft - 50 }))}>‹</button>
|
||||
<span className="cit-bz-val">{min.on ? min.ft : '– – –'}</span>
|
||||
<button className="cit-bz-knob" onClick={() => setMin((m) => ({ ...m, on: true, ft: m.ft + 50 }))}>›</button>
|
||||
<button className={`cit-bz-btn ${min.on ? 'on' : ''}`} onClick={() => setMin((m) => ({ ...m, on: !m.on }))}>MIN</button>
|
||||
</div>
|
||||
<button className={`cit-bz-btn ${raBaro ? 'on' : ''}`} onClick={() => setRaBaro((v) => !v)}>RA/BARO</button>
|
||||
<button className={`cit-bz-btn ${std ? 'on' : ''}`} onClick={() => setStd((v) => !v)}>STD</button>
|
||||
<div className="cit-bz-group">
|
||||
<span className="cit-bz-lbl">BARO SET</span>
|
||||
<button className="cit-bz-knob" onClick={() => xp.command('pfd_baro_down')}>‹</button>
|
||||
<span className="cit-bz-val">{std ? 'STD' : baro.toFixed(2)}</span>
|
||||
<button className="cit-bz-knob" onClick={() => xp.command('pfd_baro_up')}>›</button>
|
||||
</div>
|
||||
<div className="cit-bz-group">
|
||||
<span className="cit-bz-lbl">CRS</span>
|
||||
<button className="cit-bz-knob" onClick={() => xp.command('pfd_crs_down')}>‹</button>
|
||||
<button className="cit-bz-knob" onClick={() => xp.command('pfd_crs_up')}>›</button>
|
||||
</div>
|
||||
<div className="cit-bz-group">
|
||||
<span className="cit-bz-lbl">HDG</span>
|
||||
<button className="cit-bz-knob" onClick={() => xp.command('pfd_hdg_down')}>‹</button>
|
||||
<button className="cit-bz-knob" onClick={() => xp.command('pfd_hdg_sync')}>SYNC</button>
|
||||
<button className="cit-bz-knob" onClick={() => xp.command('pfd_hdg_up')}>›</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,136 @@
|
||||
import React, { useState } from 'react';
|
||||
import { num } from '../../api/useXplane.js';
|
||||
|
||||
// ============================================================================
|
||||
// Citation X — Radio Management Unit (p39-40) + Nav Source Selector (p24).
|
||||
// RMU buttons (per manual):
|
||||
// 1 COM toggle · 2 COM standby select · 3 XPDR code · 4 XPDR mode
|
||||
// 5 TCAS range · 6 TCAS mode · 7 IDENT · 8 DME · 9 NAV toggle
|
||||
// 10 NAV standby select · 11 ADF freq · 12 ADF mode · 13 radio 1/2 · 14 tune
|
||||
// Nav Source: NAV (NAV1/NAV2) · FMS · VOR1/VOR2/ADF1/ADF2 bearing-pointer source.
|
||||
// ============================================================================
|
||||
|
||||
const mhz = (hz) => (num(hz) / 100).toFixed(2);
|
||||
const XPDR = ['OFF', 'STBY', 'ON', 'ALT'];
|
||||
const TCAS_RNG = [6, 12, 20, 40];
|
||||
const TCAS_MODE = ['NORMAL', 'ABOVE', 'BELOW'];
|
||||
|
||||
export default function CitRMU({ xp, navSrc, onNavSrc }) {
|
||||
const V = xp.values || {};
|
||||
const cmd = xp.command, sd = xp.setDataref;
|
||||
const [bank, setBank] = useState(1); // tuning radio: 1 or 2
|
||||
const [sel, setSel] = useState('com'); // which standby is armed for tuning: com|nav|adf|xpdr
|
||||
const [tcasR, setTcasR] = useState(1); // index into TCAS_RNG
|
||||
const [tcasM, setTcasM] = useState(0);
|
||||
const bsrc = navSrc || { brg1: 'VOR1', brg2: 'VOR2' };
|
||||
const setBrg = (k, v) => onNavSrc && onNavSrc((s) => ({ ...s, [k]: v }));
|
||||
const r = bank; // 1 / 2
|
||||
|
||||
const tuneUp = () => cmd(`${sel}${r}CoarseUp`);
|
||||
const tuneDn = () => cmd(`${sel}${r}CoarseDown`);
|
||||
const fineUp = () => cmd(`${sel}${r}FineUp`);
|
||||
const fineDn = () => cmd(`${sel}${r}FineDown`);
|
||||
const cdi = num(V.cdiSrc); // 0 NAV1 · 1 NAV2 · 2 GPS
|
||||
|
||||
const Btn = ({ label, on, onClick, cls = '' }) => (
|
||||
<button className={`citrmu-btn ${on ? 'on' : ''} ${cls}`} onClick={onClick}>{label}</button>
|
||||
);
|
||||
|
||||
return (
|
||||
<div className="cit-screen citrmu-screen">
|
||||
<div className="citrmu-wrap">
|
||||
{/* ── RMU display ──────────────────────────────────────────── */}
|
||||
<div className="citrmu-unit">
|
||||
<div className="citrmu-row citrmu-top">
|
||||
<div className={`citrmu-radio ${sel === 'com' ? 'armed' : ''}`}>
|
||||
<div className="citrmu-h">COM{r}</div>
|
||||
<div className="citrmu-act">{mhz(V[`com${r}`])}</div>
|
||||
<div className="citrmu-sby">{mhz(V[`com${r}Sb`])}</div>
|
||||
</div>
|
||||
<div className={`citrmu-radio ${sel === 'nav' ? 'armed' : ''}`}>
|
||||
<div className="citrmu-h">NAV{r}</div>
|
||||
<div className="citrmu-act">{mhz(V[`nav${r}`])}</div>
|
||||
<div className="citrmu-sby">{mhz(V[`nav${r}Sb`])}</div>
|
||||
</div>
|
||||
</div>
|
||||
<div className="citrmu-row citrmu-mid">
|
||||
<div className={`citrmu-box ${sel === 'xpdr' ? 'armed' : ''}`}>
|
||||
<div className="citrmu-h">ATC/TCAS</div>
|
||||
<div className="citrmu-act">{String(Math.round(num(V.xpdrCode))).padStart(4, '0')}</div>
|
||||
<div className="citrmu-sub">{XPDR[num(V.xpdrMode)] || 'STBY'}</div>
|
||||
</div>
|
||||
<div className={`citrmu-box ${sel === 'adf' ? 'armed' : ''}`}>
|
||||
<div className="citrmu-h">ADF{r}</div>
|
||||
<div className="citrmu-act">{(num(V[`adf${r}`]) || 0).toFixed(1)}</div>
|
||||
<div className="citrmu-sub">ADF</div>
|
||||
</div>
|
||||
</div>
|
||||
<div className="citrmu-row citrmu-tcas">
|
||||
RANGE: {TCAS_RNG[tcasR]} <b>{TCAS_MODE[tcasM]}</b>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* ── RMU buttons ──────────────────────────────────────────── */}
|
||||
<div className="citrmu-keys">
|
||||
<div className="citrmu-kcol">
|
||||
<Btn label="COM ⇄" onClick={() => cmd(`com${r}Swap`)} />
|
||||
<Btn label="COM SBY" on={sel === 'com'} onClick={() => setSel('com')} />
|
||||
<Btn label="XPDR CODE" on={sel === 'xpdr'} onClick={() => setSel('xpdr')} />
|
||||
<Btn label="XPDR MODE" onClick={() => sd('xpdrMode', (num(V.xpdrMode) + 1) % 4)} />
|
||||
<Btn label={`TCAS ${TCAS_RNG[tcasR]}`} onClick={() => setTcasR((i) => (i + 1) % 4)} />
|
||||
<Btn label={`TCAS ${TCAS_MODE[tcasM].slice(0, 3)}`} onClick={() => setTcasM((i) => (i + 1) % 3)} />
|
||||
<Btn label="IDENT" onClick={() => cmd('xpdrIdent')} />
|
||||
</div>
|
||||
|
||||
{/* tuning rotary (#14) */}
|
||||
<div className="citrmu-tune">
|
||||
<div className="citrmu-tlbl">TUNE {sel.toUpperCase()}{r}</div>
|
||||
<div className="citrmu-trow"><button onClick={tuneDn}>◀ MHz</button><button onClick={tuneUp}>MHz ▶</button></div>
|
||||
<div className="citrmu-trow"><button onClick={fineDn}>◀ kHz</button><button onClick={fineUp}>kHz ▶</button></div>
|
||||
<button className="citrmu-12" onClick={() => setBank((b) => (b === 1 ? 2 : 1))}>1 / 2 → radio {r}</button>
|
||||
<div className="citrmu-srow">
|
||||
{sel === 'xpdr' && <>
|
||||
<button onClick={() => sd('xpdrCode', Math.max(0, num(V.xpdrCode) - 1))}>code −</button>
|
||||
<button onClick={() => sd('xpdrCode', num(V.xpdrCode) + 1)}>code +</button>
|
||||
</>}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="citrmu-kcol">
|
||||
<Btn label="NAV ⇄" onClick={() => cmd(`nav${r}Swap`)} />
|
||||
<Btn label="NAV SBY" on={sel === 'nav'} onClick={() => setSel('nav')} />
|
||||
<Btn label="ADF FREQ" on={sel === 'adf'} onClick={() => setSel('adf')} />
|
||||
<Btn label="ADF MODE" cls="dim" />
|
||||
<Btn label="DME" cls="dim" />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* ── Nav Source Selector panel (p24) ───────────────────────── */}
|
||||
<div className="citnav-sel">
|
||||
<div className="citnav-h">NAV SOURCE SELECTOR</div>
|
||||
<div className="citnav-row">
|
||||
<button className={`citnav-b ${cdi === 0 ? 'on' : ''}`} onClick={() => sd('cdiSrc', 0)}>NAV1</button>
|
||||
<button className={`citnav-b ${cdi === 1 ? 'on' : ''}`} onClick={() => sd('cdiSrc', 1)}>NAV2</button>
|
||||
<button className={`citnav-b ${cdi === 2 ? 'on' : ''}`} onClick={() => sd('cdiSrc', 2)}>FMS</button>
|
||||
</div>
|
||||
<div className="citnav-h2">BRG POINTER ◯ (blue)</div>
|
||||
<div className="citnav-row">
|
||||
{['OFF', 'VOR1', 'ADF1', 'FMS1'].map((s) => (
|
||||
<button key={s} className={`citnav-b sm ${bsrc.brg1 === s ? 'on' : ''}`} onClick={() => setBrg('brg1', s)}>{s}</button>
|
||||
))}
|
||||
</div>
|
||||
<div className="citnav-h2">BRG POINTER ◇ (white)</div>
|
||||
<div className="citnav-row">
|
||||
{['OFF', 'VOR2', 'ADF2', 'FMS2'].map((s) => (
|
||||
<button key={s} className={`citnav-b sm ${bsrc.brg2 === s ? 'on' : ''}`} onClick={() => setBrg('brg2', s)}>{s}</button>
|
||||
))}
|
||||
</div>
|
||||
<div className="citnav-note">
|
||||
NAV → CDI source (green) on the PFD · FMS = flight-plan guidance ·
|
||||
BRG pointers (◯ blue VOR1/ADF1/FMS · ◇ white VOR2/ADF2) appear on the PFD HSI.
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -2,5 +2,6 @@ import React from 'react';
|
||||
import { createRoot } from 'react-dom/client';
|
||||
import App from './App.jsx';
|
||||
import './styles.css';
|
||||
import './citation.css';
|
||||
|
||||
createRoot(document.getElementById('root')).render(<App />);
|
||||
|
||||
+54
-1
@@ -182,6 +182,13 @@ body {
|
||||
.apt-app.ils { color: #16d24a; }
|
||||
.apt-comlbl { color: #6f808d; font-size: 11px; }
|
||||
.apt-com { color: #fff; font-size: 13px; }
|
||||
/* NRST per-entry actions: load freq to standby, or fly Direct-To */
|
||||
.nrst-acts { display: flex; gap: 6px; margin-top: 2px; justify-content: flex-end; }
|
||||
.nrst-row .nrst-acts { display: inline-flex; margin-top: 0; margin-left: 6px; }
|
||||
.nrst-act { background: #11202a; border: 1px solid #2a4250; color: #7fd4ff; font: inherit; font-size: 10px; padding: 1px 6px; border-radius: 2px; cursor: pointer; letter-spacing: .5px; }
|
||||
.nrst-act:hover { background: #163243; }
|
||||
.nrst-act.dto { color: #e89bff; border-color: #5a3a66; }
|
||||
.nrst-msg { color: #16d24a; font-size: 11px; padding: 4px 8px; text-align: center; }
|
||||
.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; }
|
||||
@@ -260,6 +267,20 @@ body {
|
||||
.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; }
|
||||
/* VNAV control keys (ENBL/CNCL VNV, FPA, along-track offset, VNV Direct-To) */
|
||||
.fpl-vnav-keys { display: flex; flex-wrap: wrap; align-items: center; gap: 5px; margin-top: 7px; }
|
||||
.fpl-vnav-keys button { background: #11202a; border: 1px solid #2a4250; color: #7fd4ff; font: inherit; font-size: 11px; padding: 2px 7px; border-radius: 2px; cursor: pointer; letter-spacing: .5px; }
|
||||
.fpl-vnav-keys button:hover { background: #163243; }
|
||||
.fpl-vnav-keys button.on { background: #16d24a22; border-color: #16d24a; color: #16d24a; }
|
||||
.fpl-vnav-keys button.vnvd { color: #e89bff; border-color: #5a3a66; }
|
||||
.fpl-vnav-keys .vk-val { color: #fff; font-size: 13px; min-width: 36px; text-align: center; }
|
||||
.fpl-vnav-keys .vk-val u { color: #6f808d; font-size: 9px; text-decoration: none; }
|
||||
/* Direct-To VNAV editable fields */
|
||||
.dto-altedit { display: inline-flex; align-items: center; gap: 4px; color: #6f808d; font-size: 10px; }
|
||||
.dto-alt { width: 56px; background: #0a1016; border: 1px solid #2a4250; color: #36d2ff; font: inherit; font-size: 14px; text-align: right; padding: 1px 4px; border-radius: 2px; }
|
||||
.dto-unit { background: #11202a; border: 1px solid #2a4250; color: #7fd4ff; font: inherit; font-size: 10px; padding: 1px 5px; border-radius: 2px; cursor: pointer; }
|
||||
.dto-off { display: inline-flex; align-items: center; gap: 6px; color: #fff; font-size: 13px; }
|
||||
.dto-off button { background: #11202a; border: 1px solid #2a4250; color: #7fd4ff; font: inherit; width: 20px; height: 20px; border-radius: 2px; cursor: pointer; line-height: 1; }
|
||||
/* 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 */
|
||||
@@ -390,6 +411,10 @@ body {
|
||||
.nav-sym { position: relative; width: 18px; height: 18px; }
|
||||
.nav-lbl { position: absolute; left: 19px; top: 1px; font: 700 11px/1 monospace; white-space: nowrap;
|
||||
text-shadow: 0 0 2px #000, 0 0 2px #000, 1px 1px 1px #000; }
|
||||
/* TCAS traffic target: diamond + relative-altitude label */
|
||||
.tcas-sym { position: relative; width: 16px; height: 16px; }
|
||||
.tcas-lbl { position: absolute; left: 50%; top: -11px; transform: translateX(-50%); font: 700 10px/1 monospace; white-space: nowrap;
|
||||
text-shadow: 0 0 2px #000, 0 0 2px #000, 1px 1px 1px #000; }
|
||||
/* Bank pivots about the aircraft reference (attitude centre), which sits ~28%
|
||||
down the full-screen terrain box — so terrain roll tracks the attitude. */
|
||||
.svt-canvas { width: 100%; height: 100%; transform-origin: 50% 28%; }
|
||||
@@ -547,6 +572,10 @@ body {
|
||||
.mfd-body { flex: 1; display: flex; min-height: 0; }
|
||||
.eis-svg { width: 178px; flex-shrink: 0; height: 100%; background: #0a0a0a; border-right: 1px solid #222; }
|
||||
.mfd-map { flex: 1; position: relative; min-width: 0; }
|
||||
/* vertical profile strip (PROFILE softkey) — overlays the lower map */
|
||||
.vprof { position: absolute; left: 0; right: 0; bottom: 0; height: 26%; min-height: 120px;
|
||||
background: #05080b; border-top: 1px solid #2a4250; z-index: 500; }
|
||||
.vprof svg { width: 100%; height: 100%; display: block; }
|
||||
.leaflet-host.dark { background: #000; }
|
||||
/* G1000 chrome over the map */
|
||||
.map-chrome { position: absolute; inset: 0; pointer-events: none; z-index: 650; }
|
||||
@@ -637,7 +666,31 @@ body {
|
||||
.cdu-k:hover { border-color: #4a525b; } .cdu-k:active { transform: translateY(1px); background: #146b34; color: #fff; }
|
||||
.cdu-k.fn { font-size: 12px; letter-spacing: .5px; color: #9fb0bd; }
|
||||
.cdu-k.fn.arm { background: #7d5a10; color: #fff; border-color: #ffd24a; }
|
||||
.cdu-k.fn.exec { background: linear-gradient(#1f8f47, #146b34); color: #fff; border-color: #2ee06a; }
|
||||
.cdu-k.fn.exec { background: linear-gradient(#173a25, #0f2a1a); color: #6f9a7e; border-color: #265a39; }
|
||||
.cdu-k.fn.exec.arm { background: linear-gradient(#1f8f47, #146b34); color: #fff; border-color: #2ee06a; box-shadow: 0 0 9px rgba(46,224,106,.6); }
|
||||
.cdu-row.disco { background: rgba(255,176,0,.10); }
|
||||
.cdu-row.disco .cdu-add { color: #ffb000; }
|
||||
.cdu-row.step { box-shadow: inset 3px 0 0 #34e0ff; background: rgba(52,224,255,.10); }
|
||||
/* page-key row + multi-page FMS bodies */
|
||||
.cdu-pages { display: grid; grid-template-columns: repeat(4, 1fr); gap: 6px; margin: 2px 0 6px; }
|
||||
.cdu-k.pg { font-size: 11px; letter-spacing: .3px; color: #9fb0bd; padding: 8px 2px; }
|
||||
.cdu-k.pg.on { background: linear-gradient(#0f5a2c, #0b3f1f); color: #9fffc0; border-color: #2ee06a; }
|
||||
.cdu-body { flex: 1; display: flex; flex-direction: column; min-height: 168px; padding-top: 4px; }
|
||||
.cdu-cols2 { display: flex; flex-direction: column; }
|
||||
.cdu-row.dim .cdu-wpt { color: #6f9a7e; } .cdu-row.dim .cdu-wpt i { color: #14502a; }
|
||||
.cdu-fpln, .cdu-vnav { display: flex; flex-direction: column; gap: 10px; }
|
||||
.cdu-fl { display: flex; flex-direction: column; }
|
||||
.cdu-fl.r { align-items: flex-end; }
|
||||
.cdu-fl label { color: #1f9d52; font-size: 11px; letter-spacing: 1px; }
|
||||
.cdu-fl b { color: #fff; font-size: 19px; font-weight: 600; }
|
||||
.cdu-fl.bot { margin-top: auto; flex-direction: row; justify-content: space-between; }
|
||||
.cdu-link { color: #34e06a; font-size: 13px; } .cdu-link.r { text-align: right; }
|
||||
.cdu-deparr, .cdu-dir, .cdu-menu { display: flex; flex-direction: column; gap: 4px; }
|
||||
.cdu-tabs { display: flex; justify-content: flex-end; gap: 12px; margin-bottom: 4px; }
|
||||
.cdu-tabs span { color: #1f9d52; font-size: 12px; } .cdu-tabs span.on { color: #9fffc0; font-weight: 700; }
|
||||
.cdu-prow { display: flex; justify-content: space-between; align-items: baseline; font-size: 16px; color: #fff; padding: 3px 0; border-bottom: 1px solid #06250f; }
|
||||
.cdu-prow i { color: #1f9d52; font-style: normal; font-size: 11px; }
|
||||
.cdu-note { color: #1f9d52; font-size: 13px; padding: 4px 0; } .cdu-note.small { color: #167d3f; font-size: 11px; margin-top: auto; }
|
||||
|
||||
/* MFD */
|
||||
.mfd { display: flex; gap: 24px; align-items: center; flex-wrap: wrap; justify-content: center; width: 100%; }
|
||||
|
||||
Reference in New Issue
Block a user