19 Commits

Author SHA1 Message Date
karim 55ea7fdcc8 feat(linux): native build path + Linux-specific launcher optimizations
- X-Plane auto-detection now finds Steam (native + Flatpak), /opt and
  external-drive SteamLibrary installs, not just ~/ and macOS/Windows paths.
- Close-to-background minimizes on Linux instead of hiding to tray, so the app
  is never stranded on desktops without a tray (e.g. vanilla GNOME).
- Disable WebKitGTK's DMABUF renderer on Linux to avoid black/blank windows on
  some GPU/driver combos (overridable via the env var).
- Launcher font stack gains Linux UI/mono fallbacks (Cantarell/Ubuntu/Noto).
- scripts/build.sh: Docker-free Linux AppImage build — repack the cockpit into
  the prebuilt bundle for web-only changes; recompile natively for code changes.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-05 01:33:19 +02:00
karim b9241e60c8 feat(desktop): bundle Lua plugin resources (fms-sync, terrain-probe, ui-sync)
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-04 23:39:00 +02:00
karim e8890478dd desktop: bump version to 0.1.6 (Citation X cockpit release)
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-04 23:26:49 +02:00
karim 10d4a4facf bridge: fix exponential reconnect (OOM) + add dataref resolve probe
The X-Plane socket fired both 'error' and 'close' on a failed connect, each
scheduling a reconnect — so attempts doubled every cycle, leaking sockets/timers
until the process OOMed (~3 min with the sim down). Guard onDown so each socket
schedules exactly one reconnect (and tear the dead socket down).

Also log a one-shot resolve-probe (GET /datarefs?filter[name]=airspeed…) on
connect so a Web-API version/format mismatch is visible in the log.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-04 23:20:59 +02:00
karim 1734a2d7ac FMS/CDU: 6th line-select key, HOLD page, route STEP — manual-complete
- 6 LSKs per side now (real Boeing CDU layout); LEGS shows 6 rows; FPLN bottom
  links (ROUTE MENU / VNAV) moved to the 6th key.
- HOLD page: hold fix (active wpt), inbound course, turn direction, leg time.
- STEP (p31): 6R on LEGS steps a cyan review cursor through the route, auto-
  paging, with a "viewing <wpt>" readout — the CDU side of plan review.
- Page keys: added HOLD (now FPLN/LEGS/DEP-ARR/DIR-INTC · FIX/HOLD/VNAV/PROG · MENU).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-04 21:14:41 +02:00
karim ad592a7a77 FMS/CDU to manual completeness: FIX page, discontinuities, MOD/EXEC, 2 spd limits
- FIX INFO page (manual p17-20): reference navaid + crossing radial + distance →
  computes a fix waypoint (great-circle dest point) and inserts it into the plan.
- Flight-plan discontinuities (p25-26): coordinate-less / VECTORS legs render as
  "─ DISCONTINUITY ─" in LEGS; insert a waypoint to stitch, or LSK to clear it.
  Distance/track calc now skips disco legs (no NaN).
- MOD/ACT + EXEC light: edits arm the EXEC key (glows) and flip the page title to
  MOD…; EXEC commits/exports and clears it, like the real CDU.
- VNAV CLB/DES now take two speed/alt restrictions each (DEL clears the 2nd),
  per the manual.
- Page keys: added FIX; row relaid to 4×2 (FPLN/LEGS/DEP-ARR/DIR-INTC ·
  FIX/VNAV/PROG/MENU).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-04 20:40:12 +02:00
karim 95995211a0 Citation: persist Nav Source Selector bearing-pointer choice across sessions
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-04 20:16:06 +02:00
karim 0ceb1dede3 Citation: ADF/VOR/FMS bearing pointers wired through + PFD FMA mode bar
- Nav Source Selector (p24) now fully drives the PFD: shared brg1/brg2 state
  (App ↔ RMU ↔ PFD). RMU has both pointer knobs — ◯ blue (OFF/VOR1/ADF1/FMS1)
  and ◇ white (OFF/VOR2/ADF2/FMS2). The PFD resolves each to a magnetic bearing
  (VOR bearing, ADF relative+heading, GPS bearing) and the HSI legend reflects
  the selected sources.
- PFD FMA / AFCS mode annunciation bar across the top (lateral · AP/FD ·
  vertical) reading the per-mode *_status datarefs — active green, armed white,
  matching the real Primus PFD.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-04 20:10:13 +02:00
karim e8dfa84266 Citation: match PFD/MFD size (portrait DU-870), Nav Source switch, manual audit
- MFD reworked to the same portrait DU-870 format as the PFD (800x940) so both
  tubes are identical size side-by-side in the PFD+MFD view, like the real panel.
- Nav Source Selector now on the PFD bezel (sits under the PFD per manual p24):
  NAV (VOR1/VOR2) / FMS buttons drive HSI_source_select; the HSI course pointer,
  CDI and source label colour by source — FMS magenta, VOR green (Honeywell
  convention). MFD source label (FMS1/VOR) follows the same coupling.
- Added the airspeed trend vector (PFD #3, was missing): smoothed acceleration
  projected 10 s, magenta, on the speed tape.
- Removed dead MFD soft-keys per manual: PFD SETUP → IN/HPA baro unit; EICAS SYS
  → FUEL-HYD/ELEC/APU/ENG sub-set readout (#11/#14) with RTN.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-04 14:16:15 +02:00
karim 6756acab4a Citation: combined PFD+MFD view, hardware AP look, FMS build-out, fluid easing
- CitDuo: PFD + MFD side-by-side on one tablet screen (new 'PFD+MFD' tab,
  first in the Citation profile) — the two pilot DU-870 tubes at once.
- Autopilot restyled to the real Primus FGC: machined dark bezel w/ corner
  screws, engraved square keys with green annunciator triangles (lit when
  active), ridged pitch thumbwheel.
- FMS more complete per the FMS manual: DEP/ARR now does the two-step
  procedure→transition pick (NO TRANS / RWxx / named transitions), VNAV split
  into CLB/CRZ/DES pages (trans-alt, speed/alt limits, cruise alt, target
  speed, VPA) via PREV/NEXT, and a new PROG page (TO/DEST distance-to-go + ETE
  at GS). Page keys: FPLN/LEGS/DEP-ARR/DIR-INTC/VNAV/PROG/MENU.
- Fluidity: Citation PFD/MFD/EICAS now use the same rAF time-constant easing as
  the G1000 (useEased/useEasedAngle) for attitude, speed/alt/VS tapes, HSI,
  compass, map ownship and N1/ITT gauges — smooth 60 fps instead of stepping.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-04 12:33:04 +02:00
karim b05ffedbc1 Citation X cockpit profile: full Primus 2000 suite (PFD/MFD/EICAS/AP/RMU)
Add a switchable cockpit-profile selector (Garmin G1000 / Cessna Citation X /
GA steam) and recreate the Citation X Honeywell Primus 2000 avionics line-for-
line from the X-Plane Citation X + FMS manuals:

- CitPFD: attitude w/ FD command bars, speed tape (Vmo barber-pole, Vfe, low-
  speed red/amber bands), AOA index, altitude tape + trend, VSI, round HSI with
  CDI/course pointer + VOR/ADF bearing pointers, radar altimeter, minimums,
  STD/BARO/CRS/HDG bezel.
- CitEICAS: twin FAN%/ITT bar gauges, OIL °C/PSI, FUEL (flow/qty PPH·LBS),
  ELECTRICAL, HYDRAULICS, slat chevron, STAB trim, FLAPS, CAS message stack,
  softkeys NORM/FUEL-HYD/ELEC/CTRL-POS/ENG + control-position overlay.
- CitMFD: Honeywell heading-up arc map, FMS route (magenta active/white future),
  TCAS, terrain/WX, range arc, ETE/SAT/TAS/GSPD block, clock + ET/FT timer,
  V-SPEEDS reference card, MFD-setup overlays (TRAFFIC/TERRAIN/APTS/VOR).
- CitAP: HDG/NAV/APP/BC · ALT/VNAV/BANK/STBY · FLC/C-O/VS · pitch wheel ·
  AP/YD/M-TRIM/PFD-SEL, FMA bar + lamps from per-mode *_status datarefs.
- CitRMU: COM/NAV active+standby tuning, transponder, ADF, TCAS range/mode,
  IDENT + Nav Source Selector (NAV1/2/FMS, VOR/ADF/FMS bearing source).

Integration: all avionics stream live via the X-Plane Web API (new datarefs for
N1/N2/ITT, radar-alt, AOA, hydraulics, trim, flaps/slats/gear, control
positions, ADF, mach, yaw-damper); the existing fms-sync.lua drives the
Citation's built-in FMS (aircraft-agnostic XPLM FMS SDK). Demo seeds added so
every panel renders offline. Verified headless via Playwright (no console
errors; G1000/GA profiles unaffected).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-04 12:09:55 +02:00
karim aa64959eea FMS: build out the CDU into a multi-page airliner FMS
Expands the single-page CDU into a Collins/Boeing-style FMS per the X-Plane FMS
manual: FPLN (origin/dest/flt-no), LEGS (waypoints, insert/delete/activate),
DEP/ARR (SID/STAR/approach from the CIFP parser, with transitions), DIR
(direct-to), VNAV (cruise/target-speed/path-angle; VPA feeds the shared descent
profile), and MENU (load/store .fms). Page keys + scratchpad + LSKs + keypad.
All edits flow through the shared flight plan, which fms-sync.lua mirrors two-way
into the in-sim FMS — so the app CDU and the aircraft CDU stay synchronized
(no new Lua needed; reuses the existing sync + procedures.js).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-04 05:54:36 +02:00
karim 28ab984185 Wire MENU page-menu + HRZN HDG — last dead display keys now functional
MENU (bezel hard key) now opens a G1000-style PAGE MENU (Invert / Store / Delete
flight plan) instead of only mirroring the sim command. HRZN HDG draws heading
reference marks (N/E/S/W + ticks) along the attitude horizon, toggled from the
PFD submenu. With TRAFFIC/NEXRAD/PROFILE/PATHWAY/APTSIGNS already wired, every
softkey now does something; only ENT remains a pure sim-command mirror.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-04 03:13:12 +02:00
karim 474a35c6e3 MFD PROFILE: vertical situation view (altitude vs distance along the plan)
The PROFILE softkey was dead. Now it overlays a vertical profile at the bottom of
the MFD map: own aircraft at the left, upcoming waypoints with their target
altitudes, the magenta planned descent path, and an altitude grid — pure geometry
from the active flight plan + current altitude.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-04 03:05:40 +02:00
karim a32b5a9b06 Map: TRAFFIC (TCAS) + NEXRAD overlays — two more dead softkeys now functional
The TRAFFIC and NEXRAD map keys were dead. Now: TRAFFIC draws TCAS diamonds
(threat-coloured other/proximate/TA/RA, relative altitude + climb/descend arrow);
NEXRAD draws green/yellow/red precip cells. Both toggle from the MAP softkeys and
light when active. Driven by values.traffic / values.wxCells, which the demo
synthesizes so they're demonstrable; the live-sim binding (TCAS/weather datarefs)
is the part that still needs a sim session to wire+verify.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-04 02:57:53 +02:00
karim 5f63c5032c Desktop: first-run setup wizard + FlyWithLua/Web-API/Lua-status guidance
Adds the four onboarding pieces that were missing:
- flywithlua_present Tauri command + wizard step that checks the plugin and
  links the FlyWithLua NG+ download when it's absent.
- Wizard step explaining how to enable X-Plane's Web/REST API (Settings>Network).
- FlyWithLua-Sync status row in the live diagnostics, from /api/health.lua
  ('N Skripte aktiv' / 'FlyWithLua fehlt' / 'kein X-Plane').
- 4-step guided wizard (X-Plane folder → FlyWithLua → Web-API → install+start)
  that auto-opens on first launch and is reachable via the header Einrichten
  button; the final step hands off to the normal server start (auto-installs Lua).

Verified the wizard DOM flow + the dLua status against a live bridge.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-04 02:43:32 +02:00
karim 3d6d3f710e Fix 3rd autopilot UI (Bezel APController) + wire dead PATHWAY/APTSIGNS keys
Line-by-line control audit. APController (the MFD left-bezel autopilot mode
controller) still decoded the unreliable autopilot_state bitfield — the same bug
already fixed in AutopilotPanel and KAP140, missed in the third AP UI. Now reads
per-mode *_status datarefs so every mode key lights correctly.

PATHWAY and APTSIGNS softkeys were dead (sim-mirror only). PATHWAY now draws the
flight-plan route on the synthetic-vision terrain; APTSIGNS toggles the runway
labels. Threaded via App svtOpts → PFD → SVT.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-04 02:05:06 +02:00
karim 5f1339f8b3 Manual audit B/C/D: VNAV control, Direct-To descent, NRST actions
D — NRST page: each nearest entry can now load its tower/CTAF into COM1 standby
(→COM) or a VOR into NAV1 standby (→NAV), and fly Direct-To it (D→). Nearest now
takes xp; com/nav standby datarefs made writable.

C — Direct-To with VNAV descent: the DTO dialog's ALT (MSL/AGL) and OFFSET fields
are now editable; entering an altitude makes the target a designated VNAV fix
(alt+dsgn) and arms VNAV, so the descent profile + PFD chevrons compute.

B — VNAV control: shared vnav config (enabled/fpa/offsetNm) threaded to PFD +
FplPage. The CURRENT VNV PROFILE panel gains ENBL/CNCL VNV, FPA ±, along-track
ATK ± and VNV-D→ keys; the profile + PFD chevrons honour the chosen FPA/offset
and hide when cancelled.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-04 01:33:12 +02:00
karim 5db22c85bc Manual audit fixes A/E/F: fuel totalizer, Victor/Jet airways, TIME TO BOD
A — Fuel totalizer (SYSTEM key, renamed from ENGINE): DEC/INC/RST FUEL had no
handler. Now adjust the fuel_totalizer_sum_kg dataref (±1 gal, RST→max fuel) and
the EIS shows the calculated remaining/used. cdiSrc-style writable + demo echo.

E — AIRWAYS: was a single on/off. earth_awy.dat field 8 (airway layer) is now
parsed (1=Victor/low, 2=Jet/high; name-prefix fallback), the bbox returns it, and
the AIRWAYS softkey cycles off→all→Victor→Jet with an AIRWY-LO/-HI label.

F — CURRENT VNV PROFILE now shows TIME TO BOD (bottom of descent) once past the
top of descent, instead of only TIME TO TOD.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-04 00:59:00 +02:00
35 changed files with 2873 additions and 178 deletions
+1 -1
View File
@@ -5900,7 +5900,7 @@ dependencies = [
[[package]]
name = "xplane-cockpit"
version = "0.1.5"
version = "0.1.6"
dependencies = [
"local-ip-address",
"serde",
+1 -1
View File
@@ -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)")
+70 -9
View File
@@ -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();
}
})
+9
View File
@@ -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 -1
View File
@@ -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"
+62
View File
@@ -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
View File
@@ -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
View File
@@ -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; }
+23
View File
@@ -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.
+94
View File
@@ -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"
+40
View File
@@ -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: [
+57
View File
@@ -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
View File
@@ -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
View File
@@ -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)}>
+185
View File
@@ -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; }
}
+41 -25
View File
@@ -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
View File
@@ -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">&lt;ROUTE MENU</span><span className="cdu-link r">VNAV&gt;</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">&lt;----- 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>&lt;NO TRANS</span><i>direct</i></div>
{(selProc.transitions || []).slice(0, 4).map((t) => <div className="cdu-prow" key={t}><span>&lt;{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&gt;</span><span className={cat === 'stars' ? 'on' : ''}>STAR&gt;</span><span className={cat === 'approaches' ? 'on' : ''}>APPR&gt;</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>&lt;{p.name}</span>{p.transitions?.length ? <i>{p.transitions.length} TR&gt;</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&gt;</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) · NEXTCRZ</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>&lt;{n}</span><i>.fms</i></div>)}</div>
) : (
<div className="cdu-menu">
<div className="cdu-prow"><span>&lt;LOAD (CO ROUTE)</span></div>
<div className="cdu-prow"><span>&lt;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">&lt;------ 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>
+25 -4
View File
@@ -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>
+31 -9
View File
@@ -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">
+68 -3
View File
@@ -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>
+44 -2
View File
@@ -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 1001000 ft below, transparent otherwise (G1000 TAWS colours). Only
+35 -5
View File
@@ -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
View File
@@ -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) */}
+31 -1
View File
@@ -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);
+100
View File
@@ -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>
);
}
+15
View File
@@ -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>
);
}
+206
View File
@@ -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>
);
}
+242
View File
@@ -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 ab
function geo(aLat, aLon, bLat, bLon) {
const φ1 = toRad(aLat), φ2 = toRad(bLat), = toRad(bLat - aLat), = toRad(bLon - aLon);
const h = Math.sin( / 2) ** 2 + Math.cos(φ1) * Math.cos(φ2) * Math.sin( / 2) ** 2;
const dist = 3440.065 * 2 * Math.atan2(Math.sqrt(h), Math.sqrt(1 - h));
const y = Math.sin() * Math.cos(φ2);
const x = Math.cos(φ1) * Math.sin(φ2) - Math.sin(φ1) * Math.cos(φ2) * Math.cos();
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>
);
}
+424
View File
@@ -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). alpha14° 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 VOR1VOR2
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>
);
}
+136
View File
@@ -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]} &nbsp; <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>
);
}
+1
View File
@@ -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
View File
@@ -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%; }