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>
This commit is contained in:
2026-06-04 12:09:55 +02:00
parent aa64959eea
commit b05ffedbc1
11 changed files with 1298 additions and 17 deletions
+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 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 loop. Waypoints are pushed as lat/lon legs (exact route; in-sim idents are
generic — route accuracy over cosmetics). 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.
+12
View File
@@ -337,6 +337,18 @@ function startDemo() {
{ lat: 47.7, lon: -122.0, r: 8, lvl: 2 }, { lat: 47.75, lon: -121.9, r: 5, lvl: 3 }, { 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 }, { 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 // a sample plan so the map/FMS show something in demo mode
fp.setPlan({ name: 'DEMO', waypoints: [ fp.setPlan({ name: 'DEMO', waypoints: [
+49
View File
@@ -126,6 +126,45 @@ export const DATAREFS = {
flcStatus: 'sim/cockpit2/autopilot/speed_status', flcStatus: 'sim/cockpit2/autopilot/speed_status',
gsStatus: 'sim/cockpit2/autopilot/glideslope_status', gsStatus: 'sim/cockpit2/autopilot/glideslope_status',
vnavStatus: 'sim/cockpit2/autopilot/vnav_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). // Datarefs the frontend may WRITE (e.g. turning the heading bug knob).
@@ -164,6 +203,16 @@ export const COMMANDS = {
hdgUp: 'sim/autopilot/heading_up', hdgUp: 'sim/autopilot/heading_up',
hdgDown: 'sim/autopilot/heading_down', hdgDown: 'sim/autopilot/heading_down',
xpdrIdent: 'sim/transponder/transponder_ident', 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. // Per-radio standby tuning (coarse = MHz, fine = kHz) + active/standby flip.
+80 -17
View File
@@ -11,6 +11,12 @@ import DirectTo from './components/DirectTo.jsx';
import Proc from './components/Proc.jsx'; import Proc from './components/Proc.jsx';
import FplPage from './components/FplPage.jsx'; import FplPage from './components/FplPage.jsx';
import AudioPanel from './components/AudioPanel.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 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). // Compact line icons for the nav rail (stroke = currentColor).
const ICONS = { const ICONS = {
@@ -21,29 +27,58 @@ const ICONS = {
ap: 'M11 4a7 7 0 100 14 7 7 0 000-14zM11 4v3M11 15v3M4 11h3M15 11h3', 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', 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', 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',
}; };
function Icon({ name }) { function Icon({ name }) {
return ( return (
<svg className="snav-ic" viewBox="0 0 22 22" width="22" height="22" fill="none" <svg className="snav-ic" viewBox="0 0 22 22" width="22" height="22" fill="none"
stroke="currentColor" strokeWidth="1.6" strokeLinecap="round" strokeLinejoin="round"> 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" />} {name === 'map' && <circle cx="11" cy="8" r="2" />}
</svg> </svg>
); );
} }
const TABS = [ // Three selectable cockpit profiles. Each maps the app to a different aircraft's
{ id: 'pfd', label: 'PFD' }, // avionics suite, sharing the same bridge/datarefs underneath.
{ id: 'mfd', label: 'MFD' }, const PROFILES = {
{ id: 'map', label: 'Map' }, g1000: {
{ id: 'fms', label: 'FMS' }, label: 'Garmin G1000', short: 'G1000',
{ id: 'vfr', label: 'VFR' }, tabs: [
{ id: 'ap', label: 'Autopilot' }, { id: 'pfd', label: 'PFD' }, { id: 'mfd', label: 'MFD' }, { id: 'map', label: 'Map' },
{ id: 'audio', label: 'Audio' }, { 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: '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() { export default function App() {
const xp = useXplane(); const xp = useXplane();
// Active cockpit profile — persisted; switches the whole avionics suite.
const [profile, setProfile] = useState(() => localStorage.getItem('cockpitProfile') || 'g1000');
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'); const [tab, setTab] = useState(() => location.hash.replace('#', '') || 'pfd');
// Collapsible nav rail: narrow (icons) ↔ wide (icons + labels), remembered. // Collapsible nav rail: narrow (icons) ↔ wide (icons + labels), remembered.
const [navWide, setNavWide] = useState(() => localStorage.getItem('navWide') === '1'); const [navWide, setNavWide] = useState(() => localStorage.getItem('navWide') === '1');
@@ -93,6 +128,11 @@ export default function App() {
const uiInset = xp.values.uiInset, uiPage = xp.values.uiMfdPage; const uiInset = xp.values.uiInset, uiPage = xp.values.uiMfdPage;
useEffect(() => { if (uiInset === 0 || uiInset === 1) setInset(!!uiInset); }, [uiInset]); useEffect(() => { if (uiInset === 0 || uiInset === 1) setInset(!!uiInset); }, [uiInset]);
useEffect(() => { if (typeof uiPage === 'number' && MFD_PAGES[uiPage]) setMfdPage(MFD_PAGES[uiPage]); }, [uiPage]); 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 connKind = xp.xpConnected ? 'ok' : xp.connected ? 'warn' : 'bad';
const connText = xp.xpConnected ? 'X-PLANE' : xp.connected ? 'NO SIM' : 'OFFLINE'; const connText = xp.xpConnected ? 'X-PLANE' : xp.connected ? 'NO SIM' : 'OFFLINE';
@@ -131,10 +171,22 @@ export default function App() {
return ( return (
<div className={`app ${navWide ? 'nav-wide' : 'nav-narrow'}`}> <div className={`app ${navWide ? 'nav-wide' : 'nav-narrow'}`}>
<aside className="sidebar"> <aside className="sidebar">
<button className="sb-top" onClick={toggleNav} title="Menü ein-/ausklappen"> <button className="sb-top" onClick={() => setProfMenu((v) => !v)} title="Cockpit-Profil wählen">
<span className="brand">G<span>1000</span></span> <span className="brand">{PROF.short}</span>
<span className="sb-chev">{navWide ? '' : ''}</span> <span className="sb-chev">{profMenu ? '' : ''}</span>
</button> </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"> <nav className="snav">
{TABS.map((t) => ( {TABS.map((t) => (
<button key={t.id} className={tab === t.id ? 'snav-i active' : 'snav-i'} <button key={t.id} className={tab === t.id ? 'snav-i active' : 'snav-i'}
@@ -158,7 +210,8 @@ export default function App() {
</aside> </aside>
<main className="screen"> <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)} <Bezel variant="pfd" xp={xp} knobMode={knobMode} svt3d={svt3d} onToggleSvt={() => setSvt3d((v) => !v)}
svtOpts={svtOpts} onSvtOpt={setSvtOpts} svtOpts={svtOpts} onSvtOpt={setSvtOpts}
inset={inset} onSetInset={setInset} insetMode={insetMode} onInsetMode={setInsetMode} inset={inset} onSetInset={setInset} insetMode={insetMode} onInsetMode={setInsetMode}
@@ -173,17 +226,27 @@ export default function App() {
{dialogs} {dialogs}
</Bezel> </Bezel>
)} )}
{tab === 'mfd' && ( {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)}> <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} /> <MFD values={xp.values} flightPlan={xp.flightPlan} fp={xp.fp} mapMode={mapMode} page={mfdPage} onCycle={cycleMfd} xp={xp} vnav={vnavCfg} onVnav={setVnavCfg} />
{dialogs} {dialogs}
</Bezel> </Bezel>
)} )}
{/* ---- Cessna Citation X suite (Honeywell Primus 2000) ---- */}
{profile === 'citation' && tab === 'pfd' && <CitPFD xp={xp} />}
{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} />}
{/* ---- shared tabs ---- */}
{tab === 'map' && <MapView values={xp.values} flightPlan={xp.flightPlan} fp={xp.fp} />} {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 === 'vfr' && <VFR xp={xp} />}
{tab === 'ap' && <AutopilotPanel xp={xp} />}
{tab === 'audio' && <AudioPanel xp={xp} />} {tab === 'audio' && <AudioPanel xp={xp} />}
{tab === 'ap' && profile === 'g1000' && <AutopilotPanel xp={xp} />}
{tab === 'ap' && profile === 'ga' && <KAP140 xp={xp} />}
</main> </main>
{settings && ( {settings && (
<div className="dlg-backdrop" onClick={() => setSettings(false)}> <div className="dlg-backdrop" onClick={() => setSettings(false)}>
+150
View File
@@ -0,0 +1,150 @@
/* ============================================================================
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: 1 / 1; }
.cit-eicas { aspect-ratio: 760 / 900; }
.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 (Flight Guidance Controller)
============================================================================ */
.citap-screen { gap: 10px; }
.citap-refs { display: flex; gap: 26px; 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: 6px; 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-ap, .citap-fma .fma-ap { color: #16e000; border-left: 1px solid #2a3138; border-right: 1px solid #2a3138; }
.citap-panel {
display: flex; align-items: stretch; gap: 14px; padding: 16px 22px;
background: linear-gradient(#20262c,#14181d); border: 1px solid #2c333a; border-radius: 12px;
box-shadow: inset 0 1px 0 #353d45, 0 6px 20px rgba(0,0,0,.45);
}
.citap-col { display: flex; flex-direction: column; gap: 10px; justify-content: flex-start; }
.citap-master { margin-left: 6px; }
.citap-btn {
position: relative; min-width: 96px; padding: 11px 14px 11px 22px; text-align: left;
font-size: 13px; font-weight: 700; letter-spacing: .05em; color: #cdd6dd;
background: linear-gradient(#2c343c,#1c2228); border: 1px solid #3a434c; border-radius: 6px;
cursor: pointer; font-family: 'Roboto Mono',monospace;
}
.citap-btn .citap-arrow { position: absolute; left: 8px; color: #4a545d; font-size: 10px; }
.citap-btn:hover { background: #333d46; }
.citap-btn.active { background: #0e5a2a; border-color: #1b8a43; color: #c9ffd6; box-shadow: 0 0 8px rgba(25,190,80,.55); }
.citap-btn.active .citap-arrow { color: #16e000; }
.citap-btn.armed { background: #20262c; border-color: #6a7178; color: #fff; }
.citap-btn.armed .citap-arrow { color: #fff; }
.citap-btn.dim { opacity: .45; cursor: default; }
.citap-wheel { display: flex; flex-direction: column; align-items: center; gap: 6px; justify-content: center; padding: 0 6px; }
.citap-wlbl { font-size: 9px; color: #8b97a0; letter-spacing: .1em; }
.citap-wbtn { width: 46px; padding: 8px 0; font-size: 14px; color: #cdd6dd; background: #2a323a; border: 1px solid #3a434c; border-radius: 5px; cursor: pointer; }
.citap-wheelface { width: 46px; height: 56px; border-radius: 6px; background: repeating-linear-gradient(#1a2026,#1a2026 3px,#2b343c 3px,#2b343c 6px); border: 1px solid #3a434c; }
.citap-foot { font-size: 11px; color: #8b97a0; max-width: 640px; text-align: center; line-height: 1.5; }
.citap-foot b { color: #7fd4ff; }
/* ============================================================================
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; }
}
+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>
);
}
+205
View File
@@ -0,0 +1,205 @@
import React, { useState } from 'react';
import { num } from '../../api/useXplane.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 = [arr(V.n1, 0), arr(V.n1, 1)];
const itt = [arr(V.itt, 0), arr(V.itt, 1)];
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>
);
}
+196
View File
@@ -0,0 +1,196 @@
import React, { useState, useEffect, useRef } from 'react';
import { num } from '../../api/useXplane.js';
// ============================================================================
// Citation X — Multi-Function Display (Honeywell Primus 2000 arc map).
// Built against the manual (pages 32-33):
// 1 Heading bug · 2 Heading · 3 Compass arc · 4 FMS source · 5 future leg (white)
// 6 active leg (magenta) · 7 range arc · 8 ETE/SAT/TAS/GSPD group · 9 RNG
// 10 V-SPEEDS · 11 EICAS SYS · 12 ET/FT timer · 13 MFD setup (TRAFFIC/TERRAIN/
// APTS/VOR) · 14 PFD setup · 15 RTN · 16 WX status · 17 ownship · 18 airport
// 19 navaid · 20 digital heading bug
// ============================================================================
const RNGS = [10, 20, 40, 80, 160];
const mod360 = (d) => ((d % 360) + 360) % 360;
const toRad = (d) => (d * Math.PI) / 180;
// great-circle distance (NM) + initial bearing (deg) from a→b
function geo(aLat, aLon, bLat, bLon) {
const φ1 = toRad(aLat), φ2 = toRad(bLat), = 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 [et, setEt] = useState(0);
const etRun = useRef(false);
useEffect(() => { const id = setInterval(() => etRun.current && setEt((t) => t + 1), 1000); return () => clearInterval(id); }, []);
const lat = num(V.lat), lon = num(V.lon), hdg = num(V.heading), trk = num(V.track);
// arc map geometry: ownship near bottom, ~120° forward arc
const W = 760, H = 760, cx = W / 2, cy = 600, R = 470; // 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>);
}
}
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 760 760" preserveAspectRatio="xMidYMid meet">
<rect x={0} y={0} width={760} height={760} fill="#04070a" />
<clipPath id="mfdclip"><rect x={0} y={70} width={760} height={690} /></clipPath>
{/* heading box (#2,#20) + FMS 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={300} y={12} width={160} height={30} fill="none" stroke="#2a3138" />
<text x={380} y={33} fontSize="18" fill="#13e000" textAnchor="middle">{String(Math.round(mod360(hdg))).padStart(3, '0')}°</text>
<text x={720} y={30} fontSize="16" fill="#d24bd2" textAnchor="end">FMS1</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(560 470)">
<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>
{/* V-SPEEDS reference card (#10) — Citation X operating speeds, manual p80 */}
{vspd && (
<g transform="translate(250 120)">
<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(20 470)">
<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)} />
</>
) : (
<>
<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>
);
}
+353
View File
@@ -0,0 +1,353 @@
import React, { useRef } from 'react';
import { num } from '../../api/useXplane.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 }) {
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}
</g>
<defs>
<pattern id="barber" width="8" height="8" patternUnits="userSpaceOnUse" patternTransform="rotate(45)">
<rect width="8" height="8" fill="#fff" /><rect width="4" height="8" fill="#c0392b" />
</pattern>
</defs>
{/* selected-speed bug (magenta) */}
{bug > 20 && <polygon points={`74,${clamp(y(bug), 6, H - 6)} 64,${clamp(y(bug), 6, H - 6) - 7} 64,${clamp(y(bug), 6, H - 6) + 7}`} fill="#d24bd2" />}
{/* current readout box */}
<polygon points={`0,${mid - 20} 60,${mid - 20} 74,${mid} 60,${mid + 20} 0,${mid + 20}`} fill="#000" stroke="#cfd6dc" strokeWidth="1.4" />
<text x={50} y={mid + 8} fontSize="26" fill="#fff" textAnchor="end" fontWeight="700">{Math.round(ias)}</text>
{mach >= 0.4 && <text x={40} y={H - 6} fontSize="16" fill="#13e000" textAnchor="middle">M{mach.toFixed(2).slice(1)}</text>}
</g>
);
}
// ── AOA index (manual p22): normalised 0 (zero-lift) … 1.0 (stall); the
// pilot keeps AOA below 0.6 (30% margin). alpha≈14° ≈ stall.
function AoaIndex({ alpha }) {
const n = clamp(alpha / 14, 0, 1.05);
const H = 120, y = (v) => H - v / 1.05 * H;
return (
<g>
<text x={0} y={-6} fontSize="11" fill="#9aa6ad" textAnchor="middle">AOA</text>
<rect x={-7} y={0} width={14} height={H} fill="#0c1116" stroke="#2a3138" />
<rect x={-7} y={0} width={14} height={y(0.85)} fill="#c0392b" opacity="0.55" />
<rect x={-7} y={y(0.85)} width={14} height={y(0.6) - y(0.85)} fill="#ffb000" opacity="0.5" />
<rect x={-7} y={y(0.6)} width={14} height={H - y(0.6)} fill="#13a800" opacity="0.4" />
<polygon points={`8,${y(n)} 18,${y(n) - 6} 18,${y(n) + 6}`} fill="#fff" />
</g>
);
}
// ── altitude tape + VSI ───────────────────────────────────────────────────────
function AltTape({ alt, bug, vs, baro, std, baroHpa, minOn, minFt, raBaro }) {
const H = 560, mid = H / 2, pxft = 0.32; // px per foot
const top = alt + mid / pxft, marks = [];
for (let s = Math.ceil((alt - mid / pxft) / 100) * 100; s <= top; s += 100) {
const y = mid + (alt - s) * pxft;
marks.push(
<g key={s}>
<line x1={6} y1={y} x2={s % 500 === 0 ? 22 : 16} y2={y} stroke="#cfd6dc" strokeWidth="1.4" />
{s % 200 === 0 && <text x={26} y={y + 4} fontSize="16" fill="#e8edf1">{s}</text>}
</g>,
);
}
const baroTxt = std ? 'STD' : baroHpa ? `${Math.round(num(baro) * 33.8639)}` : num(baro).toFixed(2);
const minY = clamp(mid + (alt - minFt) * pxft, 6, H - 6);
return (
<g>
<rect x={0} y={0} width={120} height={H} fill="#0c1116" opacity="0.82" />
<clipPath id="altclip"><rect x={0} y={0} width={120} height={H} /></clipPath>
<g clipPath="url(#altclip)">
{marks}
{/* altitude trend (green, 6 s projection of VSI) */}
<rect x={2} y={Math.min(mid, mid - vs / 10 * pxft)} width={4} height={Math.abs(vs / 10 * pxft)} fill="#13e000" />
{/* minimums bug (cyan) */}
{minOn && <polygon points={`6,${minY} 22,${minY - 7} 22,${minY + 7}`} fill="#19c3e0" />}
</g>
{/* selected-altitude bug (magenta) */}
<polygon points={`0,${clamp(mid + (alt - bug) * pxft, 6, H - 6) - 8} 14,${clamp(mid + (alt - bug) * pxft, 6, H - 6) - 8} 14,${clamp(mid + (alt - bug) * pxft, 6, H - 6) + 8} 0,${clamp(mid + (alt - bug) * pxft, 6, H - 6) + 8}`} fill="#d24bd2" />
{/* current readout box */}
<polygon points={`120,${mid - 20} 30,${mid - 20} 16,${mid} 30,${mid + 20} 120,${mid + 20}`} fill="#000" stroke="#cfd6dc" strokeWidth="1.4" />
<text x={112} y={mid + 8} fontSize="25" fill="#fff" textAnchor="end" fontWeight="700">{Math.round(alt)}</text>
{/* baro setting */}
<text x={60} y={H + 26} fontSize="17" fill={std ? '#13e000' : '#19c3e0'} textAnchor="middle">{std ? 'STD' : `${baroTxt}${baroHpa ? '' : ''}`}</text>
{minOn && <text x={60} y={H + 46} fontSize="13" fill="#19c3e0" textAnchor="middle">{raBaro ? 'RA' : 'BARO'} {Math.round(minFt)}</text>}
</g>
);
}
function VSI({ vs }) {
const H = 480, mid = H / 2;
// non-linear-ish: ±2000 fpm across the scale
const y = (fpm) => mid - clamp(fpm, -2500, 2500) / 2500 * (H / 2 - 10);
const ticks = [0, 500, 1000, 2000];
return (
<g>
<rect x={0} y={0} width={48} height={H} fill="#0c1116" opacity="0.7" rx="6" />
{ticks.map((t) => (
<g key={t}>
<line x1={0} y1={y(t)} x2={t % 1000 === 0 ? 16 : 10} y2={y(t)} stroke="#9aa6ad" strokeWidth="1.2" />
<line x1={0} y1={y(-t)} x2={t % 1000 === 0 ? 16 : 10} y2={y(-t)} stroke="#9aa6ad" strokeWidth="1.2" />
{t > 0 && <text x={20} y={y(t) + 4} fontSize="11" fill="#9aa6ad">{t / 1000}</text>}
{t > 0 && <text x={20} y={y(-t) + 4} fontSize="11" fill="#9aa6ad">{t / 1000}</text>}
</g>
))}
<line x1={0} y1={mid} x2={48} y2={y(vs)} stroke="#13e000" strokeWidth="3" />
{Math.abs(vs) > 100 && <text x={24} y={vs > 0 ? 14 : H - 4} fontSize="14" fill="#13e000" textAnchor="middle" fontWeight="700">{Math.round(vs / 50) * 50}</text>}
</g>
);
}
// ── attitude ball ─────────────────────────────────────────────────────────────
function Attitude({ pitch, roll, slip, fdP, fdR, fdOn }) {
const R = 196, cx = 0, cy = 0;
const ladder = [];
for (let p = -90; p <= 90; p += 10) {
if (p === 0) continue;
const y = p * PXDEG;
const w = p % 20 === 0 ? 70 : 36;
ladder.push(
<g key={p}>
<line x1={-w / 2} y1={-y} x2={w / 2} y2={-y} stroke="#fff" strokeWidth="2" />
{p % 20 === 0 && <>
<text x={-w / 2 - 8} y={-y + 5} fontSize="14" fill="#fff" textAnchor="end">{Math.abs(p)}</text>
<text x={w / 2 + 8} y={-y + 5} fontSize="14" fill="#fff">{Math.abs(p)}</text>
</>}
</g>,
);
}
// bank scale arc marks (top)
const bankMarks = [-60, -45, -30, -20, -10, 0, 10, 20, 30, 45, 60].map((b) => {
const a = (b - 90) * Math.PI / 180, r1 = R, r2 = b % 30 === 0 || b === 0 ? R - 16 : R - 10;
return <line key={b} x1={Math.cos(a) * r1} y1={Math.sin(a) * r1} x2={Math.cos(a) * r2} y2={Math.sin(a) * r2} stroke="#fff" strokeWidth={b === 0 ? 0 : 1.6} />;
});
return (
<g>
<clipPath id="attclip"><circle cx={cx} cy={cy} r={R} /></clipPath>
<g clipPath="url(#attclip)">
{/* sky / ground, rotated by roll then pitched */}
<g transform={`rotate(${-roll})`}>
<g transform={`translate(0 ${pitch * PXDEG})`}>
<rect x={-600} y={-1200} width={1200} height={1200} fill="#3a86c8" />
<rect x={-600} y={0} width={1200} height={1200} fill="#6b4a2b" />
<line x1={-600} y1={0} x2={600} y2={0} stroke="#fff" strokeWidth="2.5" />
{ladder}
</g>
</g>
</g>
{/* fixed bank pointer + arc */}
<g transform={`rotate(${-roll})`}>
<polygon points={`0,${-R + 2} -10,${-R + 20} 10,${-R + 20}`} fill="#ffd400" />
</g>
{bankMarks}
<polygon points={`0,${-R - 2} -9,${-R - 18} 9,${-R - 18}`} fill="#fff" />
{/* slip/skid trapezoid below the bank pointer */}
<g transform={`rotate(${-roll})`}>
<rect x={-14 + clamp(slip, -1, 1) * 26} y={-R + 22} width={28} height={7} fill="#ffd400" stroke="#000" strokeWidth="0.6" />
</g>
{/* fixed aircraft reference (yellow) */}
<g>
<rect x={-2.5} y={-2.5} width={5} height={5} fill="#ffd400" />
<line x1={-90} y1={0} x2={-30} y2={0} stroke="#ffd400" strokeWidth="4" />
<line x1={30} y1={0} x2={90} y2={0} stroke="#ffd400" strokeWidth="4" />
<line x1={-30} y1={0} x2={-30} y2={12} stroke="#ffd400" strokeWidth="4" />
<line x1={30} y1={0} x2={30} y2={12} stroke="#ffd400" strokeWidth="4" />
</g>
{/* flight-director command bars (magenta V-bars) — #19 lateral / #22 vertical */}
{fdOn && (
<g transform={`translate(0 ${clamp(-fdP * PXDEG, -70, 70)}) rotate(${clamp(fdR, -30, 30)})`}>
<polyline points="-70,16 0,2 70,16" fill="none" stroke="#d24bd2" strokeWidth="4" />
</g>
)}
</g>
);
}
// ── HSI (rotating compass with CDI + bearing pointers) ─────────────────────────
function HSI({ hdg, trk, crs, hdgBug, cdi, toFrom, brg1, brg2, srcLabel }) {
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 (cyan), #5 + #15 */}
<g transform={`rotate(${mod360(crs - hdg)})`}>
<line x1={0} y1={-R + 18} x2={0} y2={-50} stroke="#19c3e0" strokeWidth="3" />
<polygon points={`0,${-R + 6} -8,${-R + 22} 8,${-R + 22}`} fill="#19c3e0" />
<line x1={0} y1={50} x2={0} y2={R - 18} stroke="#19c3e0" strokeWidth="3" />
{/* CDI bar */}
<line x1={clamp(cdi, -2, 2) * 30} y1={-46} x2={clamp(cdi, -2, 2) * 30} y2={46} stroke="#19c3e0" 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="#19c3e0" />}
</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="#13e000" textAnchor="middle">{srcLabel}</text>
</g>
);
}
export default function CitPFD({ xp }) {
const V = xp.values || {};
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 });
const ias = num(V.airspeed), alt = num(V.altitude), vs = num(V.vspeed);
const pitch = num(V.pitch), roll = num(V.roll), slip = num(V.slip);
const hdg = num(V.heading), trk = num(V.track), crs = num(V.obsCrs);
const hdgBug = num(V.apHdgBug), cdi = num(V.hsiDef), toFrom = num(V.hsiToFrom);
const baro = num(V.baro, 29.92), mach = num(V.mach);
const radAlt = num(V.radioAlt, 99999);
const fdOn = num(V.apMode) >= 1 || num(V.apEngaged) > 0;
// bearing pointers only when a station is received (finite, nonzero)
const brg1 = (num(V.nav1Dme) > 0 || num(V.nav1Brg) > 0) ? num(V.nav1Brg) : null;
const brg2 = (num(V.nav2Dme) > 0 || num(V.nav2Brg) > 0) ? num(V.nav2Brg) : null;
const srcLabel = num(V.cdiSrc) === 2 ? 'FMS1' : num(V.cdiSrc) === 1 ? 'VOR2' : 'VOR1';
const dme = num(V.cdiSrc) === 1 ? num(V.nav2Dme) : num(V.nav1Dme);
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" />
{/* 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} /></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={num(V.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} /></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) */}
<g fontSize="13">
<circle cx={28} cy={726} r="6" fill="none" stroke="#19c3e0" strokeWidth="2" />
<text x={42} y={731} fill="#19c3e0">VOR1</text>
<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">VOR2</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 — MINIMUMS rotary (#8), RA/BARO (#9), STD (#11), BARO SET (#12) */}
<div className="cit-bezel">
<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>
);
}
+129
View File
@@ -0,0 +1,129 @@
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 }) {
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 [navSrc, setNavSrc] = useState('VOR1'); // bearing-pointer source (#6 secondary)
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 ${navSrc === s ? 'on' : ''}`} onClick={() => setNavSrc(s)}>{s}</button>
))}
</div>
<div className="citnav-note">
NAV CDI source (green) on the PFD · FMS = flight-plan guidance ·
BRG pointers ( blue / white) show VOR / ADF / FMS bearings.
</div>
</div>
</div>
);
}
+1
View File
@@ -2,5 +2,6 @@ import React from 'react';
import { createRoot } from 'react-dom/client'; import { createRoot } from 'react-dom/client';
import App from './App.jsx'; import App from './App.jsx';
import './styles.css'; import './styles.css';
import './citation.css';
createRoot(document.getElementById('root')).render(<App />); createRoot(document.getElementById('root')).render(<App />);