setSettings(false)}>
diff --git a/web/src/citation.css b/web/src/citation.css
new file mode 100644
index 0000000..7334489
--- /dev/null
+++ b/web/src/citation.css
@@ -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; }
+}
diff --git a/web/src/components/citation/CitAP.jsx b/web/src/components/citation/CitAP.jsx
new file mode 100644
index 0000000..76c7da5
--- /dev/null
+++ b/web/src/components/citation/CitAP.jsx
@@ -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 }) => (
+
+ );
+
+ const sel = num(V.apAltBug);
+
+ return (
+
+ {/* selected references row (alt / hdg / spd / vs) */}
+
+
ALT SEL{Math.round(num(V.apAltBug))}
+
HDG{String(Math.round(num(V.apHdgBug)) % 360).padStart(3, '0')}
+
IAS/M{num(V.mach) >= 0.4 ? num(V.mach).toFixed(2) : Math.round(num(V.apSpdBug))}
+
VS{Math.round(num(V.apVsBug))}
+
+
+ {/* FMA annunciator bar (active = green, armed = white) */}
+
+ = 2 ? 'fma-act' : 'fma-arm'}>{lateral[0]}
+ {apOn ? 'AP' : 'FD'}{ydOn ? ' · YD' : ''}
+ = 2 ? 'fma-act' : 'fma-arm'}>{vertical[0]}
+
+
+
+
+
+
+
+
+
+
+
+
+ setBank((v) => !v)} />
+ cmd('apStby')} />
+
+
+
+ {}} />
+
+
+
+ {/* PITCH WHEEL — VS rate (in VS) or IAS/Mach target (in FLC) */}
+
+
NOSE UP
+
+
+
+
NOSE DN
+
+
+
+
+
+ setMtrim((v) => !v)} />
+ setPfdSel((p) => (p === 'PILOT' ? 'COPILOT' : 'PILOT'))} />
+
+
+
+
+ AP MASTER engages Yaw Damper automatically · PITCH WHEEL sets V/S rate or FLC speed ·
+ PFD SEL: {pfdSel} guidance{bank ? ' · LOW BANK 17°' : ''}{mtrim ? ' · MACH TRIM' : ''}
+
+
+ );
+}
diff --git a/web/src/components/citation/CitEICAS.jsx b/web/src/components/citation/CitEICAS.jsx
new file mode 100644
index 0000000..4f7a064
--- /dev/null
+++ b/web/src/components/citation/CitEICAS.jsx
@@ -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 (
+
+
+ {red != null && }
+ {red != null && }
+
+
+
+ {val.toFixed(decimals)}
+
+ );
+}
+
+// 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 (
+
+
+ = red ? '#c0392b' : '#13a800'} />
+
+ );
+}
+
+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 }) => (
+
+ );
+
+ return (
+
+
+
+
+
+
+
+
+
+
+
+
+ );
+}
diff --git a/web/src/components/citation/CitMFD.jsx b/web/src/components/citation/CitMFD.jsx
new file mode 100644
index 0000000..a6afb28
--- /dev/null
+++ b/web/src/components/citation/CitMFD.jsx
@@ -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), dφ = toRad(bLat - aLat), dλ = toRad(bLon - aLon);
+ const h = Math.sin(dφ / 2) ** 2 + Math.cos(φ1) * Math.cos(φ2) * Math.sin(dλ / 2) ** 2;
+ const dist = 3440.065 * 2 * Math.atan2(Math.sqrt(h), Math.sqrt(1 - h));
+ const y = Math.sin(dλ) * Math.cos(φ2);
+ const x = Math.cos(φ1) * Math.sin(φ2) - Math.sin(φ1) * Math.cos(φ2) * Math.cos(dλ);
+ return { dist, brg: mod360((Math.atan2(y, x) * 180) / Math.PI) };
+}
+
+export default function CitMFD({ xp }) {
+ const V = xp.values || {};
+ const fp = xp.flightPlan || { waypoints: [] };
+ const [rng, setRng] = useState(40);
+ const [ov, setOv] = useState({ traffic: true, terrain: false, apts: true, vor: true });
+ const [setup, setSetup] = useState(null); // null | 'mfd' | 'eicas' | 'pfd'
+ const [vspd, setVspd] = useState(false);
+ const [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(
);
+ 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(
{String(Math.round(h / 10) % 36).padStart(2, '0')});
+ }
+ }
+
+ 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 }) =>
;
+
+ return (
+
+
+
+
+ {setup === 'mfd' ? (
+ <>
+
setOv((o) => ({ ...o, traffic: !o.traffic }))} />
+ setOv((o) => ({ ...o, terrain: !o.terrain }))} />
+ setOv((o) => ({ ...o, apts: !o.apts }))} />
+ setOv((o) => ({ ...o, vor: !o.vor }))} />
+ setSetup(null)} />
+ >
+ ) : (
+ <>
+
+
+
+
+
+
+ RNG
+
+ {rng}
+
+
+ >
+ )}
+
+
+ );
+}
diff --git a/web/src/components/citation/CitPFD.jsx b/web/src/components/citation/CitPFD.jsx
new file mode 100644
index 0000000..c08035e
--- /dev/null
+++ b/web/src/components/citation/CitPFD.jsx
@@ -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(
+
+
+ {s % 20 === 0 && {s}}
+ ,
+ );
+ }
+ return (
+
+
+
+
+ {/* low-speed awareness: red below Vso, amber Vso→Vs1 */}
+
+
+ {/* Vmo/Mmo barber pole: overspeed band from the top down to the Vmo line */}
+
+ {/* Vfe flap-limit marker */}
+
+ {marks}
+
+
+
+
+
+
+ {/* selected-speed bug (magenta) */}
+ {bug > 20 && }
+ {/* current readout box */}
+
+ {Math.round(ias)}
+ {mach >= 0.4 && M{mach.toFixed(2).slice(1)}}
+
+ );
+}
+
+// ── 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 (
+
+ AOA
+
+
+
+
+
+
+ );
+}
+
+// ── 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(
+
+
+ {s % 200 === 0 && {s}}
+ ,
+ );
+ }
+ 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 (
+
+
+
+
+ {marks}
+ {/* altitude trend (green, 6 s projection of VSI) */}
+
+ {/* minimums bug (cyan) */}
+ {minOn && }
+
+ {/* selected-altitude bug (magenta) */}
+
+ {/* current readout box */}
+
+ {Math.round(alt)}
+ {/* baro setting */}
+ {std ? 'STD' : `${baroTxt}${baroHpa ? '' : ''}`}
+ {minOn && {raBaro ? 'RA' : 'BARO'} {Math.round(minFt)}}
+
+ );
+}
+
+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 (
+
+
+ {ticks.map((t) => (
+
+
+
+ {t > 0 && {t / 1000}}
+ {t > 0 && {t / 1000}}
+
+ ))}
+
+ {Math.abs(vs) > 100 && 0 ? 14 : H - 4} fontSize="14" fill="#13e000" textAnchor="middle" fontWeight="700">{Math.round(vs / 50) * 50}}
+
+ );
+}
+
+// ── 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(
+
+
+ {p % 20 === 0 && <>
+ {Math.abs(p)}
+ {Math.abs(p)}
+ >}
+ ,
+ );
+ }
+ // 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
;
+ });
+ return (
+
+
+
+ {/* sky / ground, rotated by roll then pitched */}
+
+
+
+
+
+ {ladder}
+
+
+
+ {/* fixed bank pointer + arc */}
+
+
+
+ {bankMarks}
+
+ {/* slip/skid trapezoid below the bank pointer */}
+
+
+
+ {/* fixed aircraft reference (yellow) */}
+
+
+
+
+
+
+
+ {/* flight-director command bars (magenta V-bars) — #19 lateral / #22 vertical */}
+ {fdOn && (
+
+
+
+ )}
+
+ );
+}
+
+// ── 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(
);
+ 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(
{lbl});
+ }
+ }
+ const ptr = (deg, color, dbl) => {
+ const a = (deg - hdg) * Math.PI / 180; // 0 = up
+ const x = Math.sin(a), y = -Math.cos(a);
+ return (
+
+
+ {/* arrow head */}
+
+ {dbl && }
+
+ );
+ };
+ return (
+
+
+ {card}
+ {/* heading bug (magenta) */}
+
+ {/* course pointer + CDI deviation (cyan), #5 + #15 */}
+
+
+
+
+ {/* CDI bar */}
+
+ {[-2, -1, 1, 2].map((d) => )}
+ {toFrom !== 0 && 0 ? '0,-14 -8,2 8,2' : '0,14 -8,-2 8,-2'} fill="#19c3e0" />}
+
+ {/* 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 */}
+
+ {String(Math.round(mod360(hdg))).padStart(3, '0')}
+ {srcLabel}
+
+ );
+}
+
+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 (
+
+
+
+ {/* bezel buttons — MINIMUMS rotary (#8), RA/BARO (#9), STD (#11), BARO SET (#12) */}
+
+
+ MINIMUMS
+
+ {min.on ? min.ft : '– – –'}
+
+
+
+
+
+
+ BARO SET
+
+ {std ? 'STD' : baro.toFixed(2)}
+
+
+
+ CRS
+
+
+
+
+ HDG
+
+
+
+
+
+
+ );
+}
diff --git a/web/src/components/citation/CitRMU.jsx b/web/src/components/citation/CitRMU.jsx
new file mode 100644
index 0000000..0701243
--- /dev/null
+++ b/web/src/components/citation/CitRMU.jsx
@@ -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 = '' }) => (
+
+ );
+
+ return (
+
+
+ {/* ── RMU display ──────────────────────────────────────────── */}
+
+
+
+
COM{r}
+
{mhz(V[`com${r}`])}
+
{mhz(V[`com${r}Sb`])}
+
+
+
NAV{r}
+
{mhz(V[`nav${r}`])}
+
{mhz(V[`nav${r}Sb`])}
+
+
+
+
+
ATC/TCAS
+
{String(Math.round(num(V.xpdrCode))).padStart(4, '0')}
+
{XPDR[num(V.xpdrMode)] || 'STBY'}
+
+
+
ADF{r}
+
{(num(V[`adf${r}`]) || 0).toFixed(1)}
+
ADF
+
+
+
+ RANGE: {TCAS_RNG[tcasR]} {TCAS_MODE[tcasM]}
+
+
+
+ {/* ── RMU buttons ──────────────────────────────────────────── */}
+
+
+ cmd(`com${r}Swap`)} />
+ setSel('com')} />
+ setSel('xpdr')} />
+ sd('xpdrMode', (num(V.xpdrMode) + 1) % 4)} />
+ setTcasR((i) => (i + 1) % 4)} />
+ setTcasM((i) => (i + 1) % 3)} />
+ cmd('xpdrIdent')} />
+
+
+ {/* tuning rotary (#14) */}
+
+
TUNE {sel.toUpperCase()}{r}
+
+
+
+
+ {sel === 'xpdr' && <>
+
+
+ >}
+
+
+
+
+ cmd(`nav${r}Swap`)} />
+ setSel('nav')} />
+ setSel('adf')} />
+
+
+
+
+
+
+ {/* ── Nav Source Selector panel (p24) ───────────────────────── */}
+
+
NAV SOURCE SELECTOR
+
+
+
+
+
+
BRG POINTER ◯ (blue)
+
+ {['OFF', 'VOR1', 'ADF1', 'FMS1'].map((s) => (
+
+ ))}
+
+
+ NAV → CDI source (green) on the PFD · FMS = flight-plan guidance ·
+ BRG pointers (◯ blue / ◇ white) show VOR / ADF / FMS bearings.
+
+
+
+ );
+}
diff --git a/web/src/main.jsx b/web/src/main.jsx
index 0857bd6..1939b21 100644
--- a/web/src/main.jsx
+++ b/web/src/main.jsx
@@ -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(
);