Citation: combined PFD+MFD view, hardware AP look, FMS build-out, fluid easing

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

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
2026-06-04 12:33:04 +02:00
parent b05ffedbc1
commit 6756acab4a
7 changed files with 211 additions and 65 deletions
+6 -3
View File
@@ -14,6 +14,7 @@ import AudioPanel from './components/AudioPanel.jsx';
import KAP140 from './components/KAP140.jsx'; import KAP140 from './components/KAP140.jsx';
import CitPFD from './components/citation/CitPFD.jsx'; import CitPFD from './components/citation/CitPFD.jsx';
import CitMFD from './components/citation/CitMFD.jsx'; import CitMFD from './components/citation/CitMFD.jsx';
import CitDuo from './components/citation/CitDuo.jsx';
import CitEICAS from './components/citation/CitEICAS.jsx'; import CitEICAS from './components/citation/CitEICAS.jsx';
import CitAP from './components/citation/CitAP.jsx'; import CitAP from './components/citation/CitAP.jsx';
import CitRMU from './components/citation/CitRMU.jsx'; import CitRMU from './components/citation/CitRMU.jsx';
@@ -29,6 +30,7 @@ const ICONS = {
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', eicas: 'M5 4v14M9 4v14M5 11h4M13 7h5M13 11h5M13 15h5',
rmu: 'M4 5h14v12H4zM7 8h8M7 11h8M7 14h4', rmu: 'M4 5h14v12H4zM7 8h8M7 11h8M7 14h4',
duo: 'M3 5h7v12H3zM12 5h7v12h-7z',
}; };
function Icon({ name }) { function Icon({ name }) {
return ( return (
@@ -54,9 +56,9 @@ const PROFILES = {
citation: { citation: {
label: 'Cessna Citation X', short: 'CITATION X', label: 'Cessna Citation X', short: 'CITATION X',
tabs: [ tabs: [
{ id: 'pfd', label: 'PFD' }, { id: 'mfd', label: 'MFD' }, { id: 'eicas', label: 'EICAS' }, { id: 'duo', label: 'PFD+MFD' }, { id: 'pfd', label: 'PFD' }, { id: 'mfd', label: 'MFD' },
{ id: 'fms', label: 'CDU/FMS' }, { id: 'ap', label: 'Autopilot' }, { id: 'rmu', label: 'Radios' }, { id: 'eicas', label: 'EICAS' }, { id: 'fms', label: 'CDU/FMS' }, { id: 'ap', label: 'Autopilot' },
{ id: 'map', label: 'Map' }, { id: 'rmu', label: 'Radios' }, { id: 'map', label: 'Map' },
], ],
}, },
ga: { ga: {
@@ -234,6 +236,7 @@ export default function App() {
)} )}
{/* ---- Cessna Citation X suite (Honeywell Primus 2000) ---- */} {/* ---- Cessna Citation X suite (Honeywell Primus 2000) ---- */}
{profile === 'citation' && tab === 'duo' && <CitDuo xp={xp} />}
{profile === 'citation' && tab === 'pfd' && <CitPFD xp={xp} />} {profile === 'citation' && tab === 'pfd' && <CitPFD xp={xp} />}
{profile === 'citation' && tab === 'mfd' && <CitMFD xp={xp} />} {profile === 'citation' && tab === 'mfd' && <CitMFD xp={xp} />}
{profile === 'citation' && tab === 'eicas' && <CitEICAS xp={xp} />} {profile === 'citation' && tab === 'eicas' && <CitEICAS xp={xp} />}
+61 -26
View File
@@ -57,46 +57,81 @@
.cit-bz-knob:hover { background: #2e3740; } .cit-bz-knob:hover { background: #2e3740; }
/* ============================================================================ /* ============================================================================
AUTOPILOT (Flight Guidance Controller) AUTOPILOT — Honeywell Primus 2000 Flight Guidance Controller (hardware look)
============================================================================ */ ============================================================================ */
.citap-screen { gap: 10px; } .citap-screen { gap: 14px; }
.citap-refs { display: flex; gap: 26px; color: #cdd6dd; font-family: 'Roboto Mono',monospace; } .citap-refs { display: flex; gap: 30px; color: #cdd6dd; font-family: 'Roboto Mono',monospace; }
.citap-refs div { text-align: center; } .citap-refs div { text-align: center; }
.citap-refs span { display: block; font-size: 10px; color: #8b97a0; letter-spacing: .08em; } .citap-refs span { display: block; font-size: 10px; color: #8b97a0; letter-spacing: .08em; }
.citap-refs b { font-size: 22px; color: #d24bd2; } .citap-refs b { font-size: 22px; color: #d24bd2; }
.citap-fma { .citap-fma {
display: flex; gap: 0; background: #05080b; border: 1px solid #2a3138; border-radius: 6px; overflow: hidden; display: flex; gap: 0; background: #05080b; border: 1px solid #2a3138; border-radius: 4px; overflow: hidden;
font-family: 'Roboto Mono',monospace; font-weight: 700; min-width: 360px; 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; } .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; } .fma-act { color: #16e000; } .fma-arm { color: #fff; }
.citap-fma .fma-ap { color: #16e000; border-left: 1px solid #2a3138; border-right: 1px solid #2a3138; }
/* the controller body: machined dark-grey bezel with screws */
.citap-panel { .citap-panel {
display: flex; align-items: stretch; gap: 14px; padding: 16px 22px; position: relative; display: flex; align-items: stretch; gap: 10px; padding: 20px 26px;
background: linear-gradient(#20262c,#14181d); border: 1px solid #2c333a; border-radius: 12px; background: linear-gradient(#3a4047 0%,#23282e 8%,#1a1e23 92%,#2b3137 100%);
box-shadow: inset 0 1px 0 #353d45, 0 6px 20px rgba(0,0,0,.45); border: 1px solid #0c0f12; border-radius: 10px;
box-shadow: inset 0 1px 0 rgba(255,255,255,.08), inset 0 0 0 1px #0c0f12, 0 8px 26px rgba(0,0,0,.6);
} }
.citap-col { display: flex; flex-direction: column; gap: 10px; justify-content: flex-start; } .citap-panel::before, .citap-panel::after {
.citap-master { margin-left: 6px; } content: ''; position: absolute; width: 7px; height: 7px; border-radius: 50%;
background: radial-gradient(circle at 35% 35%,#5a626a,#1b1f24); top: 7px;
}
.citap-panel::before { left: 8px; } .citap-panel::after { right: 8px; }
.citap-col { display: flex; flex-direction: column; gap: 9px; justify-content: flex-start; }
.citap-master { margin-left: 8px; border-left: 1px solid #0c0f12; padding-left: 14px; }
/* engraved square key with a green annunciator triangle (lit when active) */
.citap-btn { .citap-btn {
position: relative; min-width: 96px; padding: 11px 14px 11px 22px; text-align: left; position: relative; width: 82px; padding: 9px 8px 9px 20px; text-align: left;
font-size: 13px; font-weight: 700; letter-spacing: .05em; color: #cdd6dd; font-size: 12px; font-weight: 700; letter-spacing: .06em; color: #e4e9ee;
background: linear-gradient(#2c343c,#1c2228); border: 1px solid #3a434c; border-radius: 6px; background: linear-gradient(#33393f,#1c2025); border: 1px solid #0e1114;
cursor: pointer; font-family: 'Roboto Mono',monospace; border-radius: 4px; cursor: pointer; font-family: 'Roboto Mono',monospace;
box-shadow: inset 0 1px 0 rgba(255,255,255,.10), inset 0 -2px 3px rgba(0,0,0,.5), 0 1px 2px rgba(0,0,0,.6);
text-shadow: 0 1px 1px #000;
} }
.citap-btn .citap-arrow { position: absolute; left: 8px; color: #4a545d; font-size: 10px; } .citap-btn .citap-arrow {
.citap-btn:hover { background: #333d46; } position: absolute; left: 7px; top: 50%; transform: translateY(-50%);
.citap-btn.active { background: #0e5a2a; border-color: #1b8a43; color: #c9ffd6; box-shadow: 0 0 8px rgba(25,190,80,.55); } width: 0; height: 0; border-top: 5px solid transparent; border-bottom: 5px solid transparent;
.citap-btn.active .citap-arrow { color: #16e000; } border-left: 7px solid #2b3137; font-size: 0; line-height: 0; color: transparent;
.citap-btn.armed { background: #20262c; border-color: #6a7178; color: #fff; } }
.citap-btn.armed .citap-arrow { color: #fff; } .citap-btn:hover { background: linear-gradient(#3b424a,#23282e); }
.citap-btn.dim { opacity: .45; cursor: default; } .citap-btn:active { box-shadow: inset 0 2px 4px rgba(0,0,0,.7); }
.citap-wheel { display: flex; flex-direction: column; align-items: center; gap: 6px; justify-content: center; padding: 0 6px; } .citap-btn.active .citap-arrow { border-left-color: #1fff4e; filter: drop-shadow(0 0 4px #16e000); }
.citap-wlbl { font-size: 9px; color: #8b97a0; letter-spacing: .1em; } .citap-btn.active { color: #fff; }
.citap-wbtn { width: 46px; padding: 8px 0; font-size: 14px; color: #cdd6dd; background: #2a323a; border: 1px solid #3a434c; border-radius: 5px; cursor: pointer; } .citap-btn.armed .citap-arrow { border-left-color: #fff; }
.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-btn.armed { color: #fff; }
.citap-foot { font-size: 11px; color: #8b97a0; max-width: 640px; text-align: center; line-height: 1.5; } .citap-btn.dim { opacity: .4; cursor: default; }
/* pitch wheel: NOSE UP/DN labels + a ridged thumbwheel */
.citap-wheel { display: flex; flex-direction: column; align-items: center; gap: 4px; justify-content: center; padding: 0 10px; }
.citap-wlbl { font-size: 8px; color: #aeb8bf; letter-spacing: .14em; }
.citap-wbtn { width: 30px; padding: 3px 0; font-size: 12px; color: #cdd6dd; background: #21262b; border: 1px solid #0e1114; border-radius: 3px; cursor: pointer; }
.citap-wheelface {
width: 30px; height: 64px; border-radius: 5px; border: 1px solid #0e1114;
background: repeating-linear-gradient(#0d0f12,#0d0f12 2px,#3a424a 3px,#22272c 5px,#0d0f12 7px);
box-shadow: inset 2px 0 4px rgba(0,0,0,.7), inset -2px 0 4px rgba(0,0,0,.7);
}
.citap-foot { font-size: 11px; color: #8b97a0; max-width: 660px; text-align: center; line-height: 1.5; }
.citap-foot b { color: #7fd4ff; } .citap-foot b { color: #7fd4ff; }
/* ---- combined PFD + MFD (one tablet screen) ---- */
.cit-duo { display: flex; width: 100%; height: 100%; background: #05080b; }
.cit-duo-half { flex: 1; min-width: 0; height: 100%; display: flex; }
.cit-duo .cit-screen { padding: 8px; gap: 8px; }
.cit-duo .cit-bezel { padding: 5px 7px; gap: 5px; }
.cit-duo .cit-bz-btn, .cit-duo .cit-sk { min-width: 0; padding: 5px 7px; font-size: 10px; }
.cit-duo .cit-bz-group { padding: 1px 5px; }
.cit-duo .cit-bz-val { min-width: 38px; font-size: 11px; }
.cit-duo .cit-bz-knob { width: 22px; padding: 4px 0; }
@media (max-width: 900px) { .cit-duo { flex-direction: column; } }
/* ============================================================================ /* ============================================================================
RADIO MANAGEMENT UNIT + Nav source selector RADIO MANAGEMENT UNIT + Nav source selector
============================================================================ */ ============================================================================ */
+86 -14
View File
@@ -23,8 +23,9 @@ function brng(a, b) {
return (deg(Math.atan2(y, x)) + 360) % 360; return (deg(Math.atan2(y, x)) + 360) % 360;
} }
const PAGE_KEYS = [['fpln', 'FPLN'], ['legs', 'LEGS'], ['deparr', 'DEP/ARR'], ['dir', 'DIR'], ['vnav', 'VNAV'], ['menu', 'MENU']]; const PAGE_KEYS = [['fpln', 'FPLN'], ['legs', 'LEGS'], ['deparr', 'DEP/ARR'], ['dir', 'DIR-INTC'], ['vnav', 'VNAV'], ['prog', 'PROG'], ['menu', 'MENU']];
const LEG_ROWS = 5; const LEG_ROWS = 5;
const VNAV_PAGES = ['CLB', 'CRZ', 'DES'];
export default function CDU({ xp, vnav: vnavCfg, onVnav }) { export default function CDU({ xp, vnav: vnavCfg, onVnav }) {
const { flightPlan, fp, exportMsg, command } = xp; const { flightPlan, fp, exportMsg, command } = xp;
@@ -42,10 +43,15 @@ export default function CDU({ xp, vnav: vnavCfg, onVnav }) {
const [procs, setProcs] = useState(null); const [procs, setProcs] = useState(null);
const [cat, setCat] = useState('sids'); // sids | stars | approaches const [cat, setCat] = useState('sids'); // sids | stars | approaches
const [procPage, setProcPage] = useState(0); const [procPage, setProcPage] = useState(0);
const [selProc, setSelProc] = useState(null); // procedure awaiting a transition pick
// VNAV perf (FMS init values; VPA feeds the shared descent profile) // VNAV perf (FMS init values; VPA feeds the shared descent profile)
const cfg = vnavCfg || { fpa: 3, offsetNm: 0, enabled: true }; const cfg = vnavCfg || { fpa: 3, offsetNm: 0, enabled: true };
const [crzAlt, setCrzAlt] = useState(''); const [crzAlt, setCrzAlt] = useState('');
const [tgtSpd, setTgtSpd] = useState(''); const [tgtSpd, setTgtSpd] = useState('');
const [transAlt, setTransAlt] = useState('18000'); // CLB transition altitude (manual default)
const [clbLim, setClbLim] = useState('250/10000'); // climb speed/alt restriction
const [desLim, setDesLim] = useState('250/10000'); // descent speed/alt restriction
const [vnavPage, setVnavPage] = useState(0); // 0 CLB · 1 CRZ · 2 DES
// saved-plan list (MENU) // saved-plan list (MENU)
const [plans, setPlans] = useState(null); const [plans, setPlans] = useState(null);
@@ -128,21 +134,37 @@ export default function CDU({ xp, vnav: vnavCfg, onVnav }) {
return; return;
} }
if (page === 'deparr') { if (page === 'deparr') {
if (side === 'R' && r < 3) { setCat(['sids', 'stars', 'approaches'][r]); setProcPage(0); return; } if (side === 'R' && r < 3) { setCat(['sids', 'stars', 'approaches'][r]); setProcPage(0); setSelProc(null); return; }
if (side === 'L') { if (side === 'L') {
// step 1: a procedure is selected → if it has transitions, pick one; else load
if (selProc) {
if (r === 0) return loadProc(selProc.name, ''); // NO TRANS / direct
const tr = (selProc.transitions || [])[r - 1];
if (tr) return loadProc(selProc.name, tr);
return;
}
const list = (procs && procs[cat]) || []; const list = (procs && procs[cat]) || [];
const p = list[procPage * 5 + r]; const p = list[procPage * 5 + r];
if (p) return loadProc(p.name, p.transitions && p.transitions[0]); if (p) { if (p.transitions && p.transitions.length) return setSelProc(p); return loadProc(p.name, ''); }
} }
return; return;
} }
if (page === 'dir') { if (scr) return directTo(scr); return; } if (page === 'dir') { if (scr) return directTo(scr); return; }
if (page === 'vnav') { if (page === 'vnav') {
if (vnavPage === 0) { // CLB: trans alt (1L), speed/alt limit (2L)
if (side === 'L' && r === 0 && scr) { setTransAlt(scr); setScr(''); return; }
if (side === 'L' && r === 1 && scr) { setClbLim(scr); setScr(''); return; }
} else if (vnavPage === 1) { // CRZ: cruise alt (1L), target speed (1R)
if (side === 'L' && r === 0 && scr) { setCrzAlt(scr); setScr(''); return; } if (side === 'L' && r === 0 && scr) { setCrzAlt(scr); setScr(''); return; }
if (side === 'R' && r === 0 && scr) { setTgtSpd(scr.replace('/', '')); setScr(''); return; } if (side === 'R' && r === 0 && scr) { setTgtSpd(scr.replace('/', '')); setScr(''); return; }
if (side === 'R' && r === 2 && scr && onVnav) { const v = parseFloat(scr); if (v >= 2 && v <= 6) onVnav((c) => ({ ...c, fpa: v })); setScr(''); return; } } else { // DES: VPA (1R), speed/alt limit (2L), target speed (1L)
if (side === 'R' && r === 0 && scr && onVnav) { const v = parseFloat(scr); if (v >= 2 && v <= 6) onVnav((c) => ({ ...c, fpa: v })); setScr(''); return; }
if (side === 'L' && r === 0 && scr) { setTgtSpd(scr.replace('/', '')); setScr(''); return; }
if (side === 'L' && r === 1 && scr) { setDesLim(scr); setScr(''); return; }
}
return; return;
} }
if (page === 'prog') return;
if (page === 'menu') { if (page === 'menu') {
if (side === 'L' && r === 0) return openLoad(); if (side === 'L' && r === 0) return openLoad();
if (side === 'L' && r === 1) return exec(); if (side === 'L' && r === 1) return exec();
@@ -183,14 +205,45 @@ export default function CDU({ xp, vnav: vnavCfg, onVnav }) {
: (<><span className="cdu-wpt">{row.id}<i>{row.type}</i></span><span className="cdu-dtk">{row.dtk == null ? '---' : `${String(row.dtk).padStart(3, '0')}°`}</span><span className="cdu-dist">{row.orig ? 'ORIG' : row.d.toFixed(1)}</span></>)} : (<><span className="cdu-wpt">{row.id}<i>{row.type}</i></span><span className="cdu-dtk">{row.dtk == null ? '---' : `${String(row.dtk).padStart(3, '0')}°`}</span><span className="cdu-dist">{row.orig ? 'ORIG' : row.d.toFixed(1)}</span></>)}
</div>))}</div>); </div>))}</div>);
} else if (page === 'deparr') { } else if (page === 'deparr') {
title = `${dest || '----'} ${cat === 'sids' ? 'DEPART' : cat === 'stars' ? 'ARRIVAL' : 'APPROACH'}`; pageNo = `${procPage + 1}/${procPages}`; const kind = cat === 'sids' ? 'DEPART' : cat === 'stars' ? 'ARRIVAL' : 'APPROACH';
if (selProc) {
title = `${selProc.name} TRANS`; pageNo = `1/1`;
body = (
<div className="cdu-deparr">
<div className="cdu-prow act"><span>&lt;NO TRANS</span><i>direct</i></div>
{(selProc.transitions || []).slice(0, 4).map((t) => <div className="cdu-prow" key={t}><span>&lt;{t}</span></div>)}
<div className="cdu-note small">Transition wählen (LSK) · oder NO TRANS (1L)</div>
</div>
);
} else {
title = `${dest || '----'} ${kind}`; pageNo = `${procPage + 1}/${procPages}`;
const shown = procList.slice(procPage * 5, procPage * 5 + 5); const shown = procList.slice(procPage * 5, procPage * 5 + 5);
body = ( body = (
<div className="cdu-deparr"> <div className="cdu-deparr">
<div className="cdu-tabs"><span className={cat === 'sids' ? 'on' : ''}>SID&gt;</span><span className={cat === 'stars' ? 'on' : ''}>STAR&gt;</span><span className={cat === 'approaches' ? 'on' : ''}>APPR&gt;</span></div> <div className="cdu-tabs"><span className={cat === 'sids' ? 'on' : ''}>SID&gt;</span><span className={cat === 'stars' ? 'on' : ''}>STAR&gt;</span><span className={cat === 'approaches' ? 'on' : ''}>APPR&gt;</span></div>
{!procs && <div className="cdu-note">{dest ? 'loading…' : 'set DEST on FPLN'}</div>} {!procs && <div className="cdu-note">{dest ? 'loading…' : 'set DEST on FPLN'}</div>}
{procs && shown.length === 0 && <div className="cdu-note">none</div>} {procs && shown.length === 0 && <div className="cdu-note">none</div>}
{shown.map((p, i) => <div className="cdu-prow" key={p.name + i}><span>&lt;{p.name}</span>{p.transitions?.length ? <i>{p.transitions.length} TR</i> : null}</div>)} {shown.map((p, i) => <div className="cdu-prow" key={p.name + i}><span>&lt;{p.name}</span>{p.transitions?.length ? <i>{p.transitions.length} TR&gt;</i> : null}</div>)}
</div>
);
}
} else if (page === 'prog') {
title = 'PROGRESS';
const here = { lat: num(xp.values.lat), lon: num(xp.values.lon) };
const gs = Math.max(1, Math.round(num(xp.values.groundspeed) * 1.94384));
const actW = wps[active], destW = wps[wps.length - 1];
const dA = actW && here.lat ? distNm(here, actW) : 0;
const dD = destW && here.lat ? distNm(here, destW) : 0;
const ete = (d) => (gs > 5 ? `${Math.floor(d / gs * 60)}:${String(Math.round((d / gs * 60 % 1) * 60)).padStart(2, '0')}` : '--:--');
body = (
<div className="cdu-vnav">
<div className="cdu-fl"><label>TO</label><b>{actW?.id || '----'}</b></div>
<div className="cdu-fl r"><label>DTG</label><b>{dA.toFixed(1)} NM</b></div>
<div className="cdu-fl r"><label>ETE</label><b>{ete(dA)}</b></div>
<div className="cdu-fl"><label>DEST</label><b>{destW?.id || '----'}</b></div>
<div className="cdu-fl r"><label>DEST DTG</label><b>{dD.toFixed(0)} NM</b></div>
<div className="cdu-fl r"><label>DEST ETE</label><b>{ete(dD)}</b></div>
<div className="cdu-note small">GS {gs} KT · TAS {Math.round(num(xp.values.tas))} · SAT {Math.round(num(xp.values.oat))}°C</div>
</div> </div>
); );
} else if (page === 'dir') { } else if (page === 'dir') {
@@ -203,15 +256,34 @@ export default function CDU({ xp, vnav: vnavCfg, onVnav }) {
</div> </div>
); );
} else if (page === 'vnav') { } else if (page === 'vnav') {
title = 'ACT VNAV'; title = `ACT VNAV ${VNAV_PAGES[vnavPage]}`; pageNo = `${vnavPage + 1}/3`;
if (vnavPage === 0) { // CLB (manual p21-22)
body = ( body = (
<div className="cdu-vnav"> <div className="cdu-vnav">
<div className="cdu-fl"><label>CRZ ALT</label><b>{crzAlt || '-----'}</b></div> <div className="cdu-fl"><label>TRANS ALT</label><b>{transAlt || '18000'}</b></div>
<div className="cdu-fl r"><label>TGT SPD</label><b>{tgtSpd || '---'}</b></div> <div className="cdu-fl r"><label>TGT SPD</label><b>{tgtSpd || '290/.74'}</b></div>
<div className="cdu-fl r"><label>FPA / VPA</label><b>{(cfg.fpa || 3).toFixed(1)}°</b></div> <div className="cdu-fl"><label>SPD/ALT LIMIT</label><b>{clbLim}</b></div>
<div className="cdu-note small">CRZ ALT: LSK1L · TGT SPD: LSK1R · VPA: LSK3R</div> <div className="cdu-note small">TRANS ALT: 1L · SPD/ALT LIMIT: 2L · NEXTCRZ</div>
</div> </div>
); );
} else if (vnavPage === 1) { // CRZ (manual p23)
body = (
<div className="cdu-vnav">
<div className="cdu-fl"><label>CRZ ALT</label><b>{crzAlt ? `FL${crzAlt}` : '-----'}</b></div>
<div className="cdu-fl r"><label>TGT SPD</label><b>{tgtSpd || '.80'}</b></div>
<div className="cdu-note small">CRZ ALT: 1L (e.g. 280=FL280) · TGT SPD: 1R</div>
</div>
);
} else { // DES (manual p23-24)
body = (
<div className="cdu-vnav">
<div className="cdu-fl"><label>TGT SPD</label><b>{tgtSpd || '/200'}</b></div>
<div className="cdu-fl r"><label>VPA</label><b>{(cfg.fpa || 3).toFixed(1)}°</b></div>
<div className="cdu-fl"><label>SPD/ALT LIMIT</label><b>{desLim}</b></div>
<div className="cdu-note small">TGT SPD: 1L · VPA: 1R (2.0-6.0°) · SPD/ALT: 2L</div>
</div>
);
}
} else if (page === 'menu') { } else if (page === 'menu') {
title = plans ? 'CO ROUTE LIST' : 'ROUTE MENU'; title = plans ? 'CO ROUTE LIST' : 'ROUTE MENU';
body = plans ? ( body = plans ? (
@@ -247,13 +319,13 @@ export default function CDU({ xp, vnav: vnavCfg, onVnav }) {
{/* page keys */} {/* page keys */}
<div className="cdu-pages"> <div className="cdu-pages">
{PAGE_KEYS.map(([id, lbl]) => ( {PAGE_KEYS.map(([id, lbl]) => (
<button key={id} className={`cdu-k pg ${page === id ? 'on' : ''}`} onClick={() => { setPage(id); setScr(''); setDel(false); setPlans(null); }}>{lbl}</button> <button key={id} className={`cdu-k pg ${page === id ? 'on' : ''}`} onClick={() => { setPage(id); setScr(''); setDel(false); setPlans(null); setSelProc(null); }}>{lbl}</button>
))} ))}
</div> </div>
<div className="cdu-fn"> <div className="cdu-fn">
<button className="cdu-k fn" onClick={() => { if (page === 'legs') setLegPage((p) => Math.max(0, p - 1)); else if (page === 'deparr') setProcPage((p) => Math.max(0, p - 1)); }}>PREV</button> <button className="cdu-k fn" onClick={() => { if (page === 'legs') setLegPage((p) => Math.max(0, p - 1)); else if (page === 'deparr') setProcPage((p) => Math.max(0, p - 1)); else if (page === 'vnav') setVnavPage((p) => Math.max(0, p - 1)); }}>PREV</button>
<button className="cdu-k fn" onClick={() => { if (page === 'legs') setLegPage((p) => Math.min(legPages - 1, p + 1)); else if (page === 'deparr') setProcPage((p) => Math.min(procPages - 1, p + 1)); }}>NEXT</button> <button className="cdu-k fn" onClick={() => { if (page === 'legs') setLegPage((p) => Math.min(legPages - 1, p + 1)); else if (page === 'deparr') setProcPage((p) => Math.min(procPages - 1, p + 1)); else if (page === 'vnav') setVnavPage((p) => Math.min(2, p + 1)); }}>NEXT</button>
<button className={`cdu-k fn ${del ? 'arm' : ''}`} onClick={() => { setDel((d) => !d); setScr(''); }}>DEL</button> <button className={`cdu-k fn ${del ? 'arm' : ''}`} onClick={() => { setDel((d) => !d); setScr(''); }}>DEL</button>
<button className="cdu-k fn" onClick={clr}>CLR</button> <button className="cdu-k fn" onClick={clr}>CLR</button>
<button className="cdu-k fn exec" onClick={exec}>EXEC</button> <button className="cdu-k fn exec" onClick={exec}>EXEC</button>
+15
View File
@@ -0,0 +1,15 @@
import React from 'react';
import CitPFD from './CitPFD.jsx';
import CitMFD from './CitMFD.jsx';
// Side-by-side PFD + MFD — the two pilot tubes of the Citation X panel on one
// tablet screen (landscape). Each keeps its own bezel/soft-keys; they scale to
// fill half the width like the real instrument panel (DU-870 displays).
export default function CitDuo({ xp }) {
return (
<div className="cit-duo">
<div className="cit-duo-half"><CitPFD xp={xp} /></div>
<div className="cit-duo-half"><CitMFD xp={xp} /></div>
</div>
);
}
+3 -2
View File
@@ -1,5 +1,6 @@
import React, { useState } from 'react'; import React, { useState } from 'react';
import { num } from '../../api/useXplane.js'; import { num } from '../../api/useXplane.js';
import { useEased } from '../../api/ease.js';
// ============================================================================ // ============================================================================
// Citation X — Engine Indicating & Crew Alerting System (EICAS). // Citation X — Engine Indicating & Crew Alerting System (EICAS).
@@ -48,8 +49,8 @@ export default function CitEICAS({ xp }) {
const V = xp.values || {}; const V = xp.values || {};
const [page, setPage] = useState('norm'); // norm | fuel | elec | ctrl | eng const [page, setPage] = useState('norm'); // norm | fuel | elec | ctrl | eng
const n1 = [arr(V.n1, 0), arr(V.n1, 1)]; const n1 = [useEased(arr(V.n1, 0), 0.16), useEased(arr(V.n1, 1), 0.16)];
const itt = [arr(V.itt, 0), arr(V.itt, 1)]; const itt = [useEased(arr(V.itt, 0), 0.2), useEased(arr(V.itt, 1), 0.2)];
const oilT = [arr(V.oilTemp, 0), arr(V.oilTemp, 1)]; const oilT = [arr(V.oilTemp, 0), arr(V.oilTemp, 1)];
const oilP = [arr(V.oilPress, 0), arr(V.oilPress, 1)]; const oilP = [arr(V.oilPress, 0), arr(V.oilPress, 1)];
const ff = [arr(V.fuelFlow, 0), arr(V.fuelFlow, 1)]; const ff = [arr(V.fuelFlow, 0), arr(V.fuelFlow, 1)];
+6 -1
View File
@@ -1,5 +1,6 @@
import React, { useState, useEffect, useRef } from 'react'; import React, { useState, useEffect, useRef } from 'react';
import { num } from '../../api/useXplane.js'; import { num } from '../../api/useXplane.js';
import { useEased, useEasedAngle } from '../../api/ease.js';
// ============================================================================ // ============================================================================
// Citation X — Multi-Function Display (Honeywell Primus 2000 arc map). // Citation X — Multi-Function Display (Honeywell Primus 2000 arc map).
@@ -35,7 +36,11 @@ export default function CitMFD({ xp }) {
const etRun = useRef(false); const etRun = useRef(false);
useEffect(() => { const id = setInterval(() => etRun.current && setEt((t) => t + 1), 1000); return () => clearInterval(id); }, []); 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); // smooth ownship + compass (same rAF glide as the G1000 map)
const lat = useEased(num(V.lat), 0.14);
const lon = useEased(num(V.lon), 0.14);
const hdg = useEasedAngle(num(V.heading), 0.10);
const trk = num(V.track);
// arc map geometry: ownship near bottom, ~120° forward arc // arc map geometry: ownship near bottom, ~120° forward arc
const W = 760, H = 760, cx = W / 2, cy = 600, R = 470; // compass radius const W = 760, H = 760, cx = W / 2, cy = 600, R = 470; // compass radius
const pxPerNm = R / rng; const pxPerNm = R / rng;
+23 -8
View File
@@ -1,5 +1,6 @@
import React, { useRef } from 'react'; import React, { useRef } from 'react';
import { num } from '../../api/useXplane.js'; import { num } from '../../api/useXplane.js';
import { useEased, useEasedAngle } from '../../api/ease.js';
// ============================================================================ // ============================================================================
// Cessna Citation X — Primary Flight Display (Honeywell Primus 2000). // Cessna Citation X — Primary Flight Display (Honeywell Primus 2000).
@@ -264,16 +265,30 @@ export default function CitPFD({ xp }) {
const [min, setMin] = React.useState({ on: false, ft: 200 }); const [min, setMin] = React.useState({ on: false, ft: 200 });
const trend = useRef({ ias: 0, t: 0 }); const trend = useRef({ ias: 0, t: 0 });
const ias = num(V.airspeed), alt = num(V.altitude), vs = num(V.vspeed); // Smooth the moving symbology toward the live datarefs (frame-rate-independent
const pitch = num(V.pitch), roll = num(V.roll), slip = num(V.slip); // easing) — the same rAF glide the G1000 uses, so a 10-20 Hz stream renders as
const hdg = num(V.heading), trk = num(V.track), crs = num(V.obsCrs); // fluid 60 fps motion instead of stepping.
const hdgBug = num(V.apHdgBug), cdi = num(V.hsiDef), toFrom = num(V.hsiToFrom); const ias = useEased(num(V.airspeed), 0.10);
const baro = num(V.baro, 29.92), mach = num(V.mach); const alt = useEased(num(V.altitude), 0.12);
const vs = useEased(num(V.vspeed), 0.18);
const pitch = useEased(num(V.pitch), 0.07);
const roll = useEased(num(V.roll), 0.07);
const slip = useEased(num(V.slip), 0.12);
const hdg = useEasedAngle(num(V.heading), 0.08);
const crs = useEasedAngle(num(V.obsCrs), 0.10);
const hdgBug = useEasedAngle(num(V.apHdgBug), 0.10);
const cdi = useEased(num(V.hsiDef), 0.12);
const mach = useEased(num(V.mach), 0.2);
const aoa = useEased(num(V.aoa), 0.12);
const brg1e = useEasedAngle(num(V.nav1Brg), 0.12);
const brg2e = useEasedAngle(num(V.nav2Brg), 0.12);
const trk = num(V.track), toFrom = num(V.hsiToFrom);
const baro = num(V.baro, 29.92);
const radAlt = num(V.radioAlt, 99999); const radAlt = num(V.radioAlt, 99999);
const fdOn = num(V.apMode) >= 1 || num(V.apEngaged) > 0; const fdOn = num(V.apMode) >= 1 || num(V.apEngaged) > 0;
// bearing pointers only when a station is received (finite, nonzero) // 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 brg1 = (num(V.nav1Dme) > 0 || num(V.nav1Brg) > 0) ? brg1e : null;
const brg2 = (num(V.nav2Dme) > 0 || num(V.nav2Brg) > 0) ? num(V.nav2Brg) : null; const brg2 = (num(V.nav2Dme) > 0 || num(V.nav2Brg) > 0) ? brg2e : null;
const srcLabel = num(V.cdiSrc) === 2 ? 'FMS1' : num(V.cdiSrc) === 1 ? 'VOR2' : 'VOR1'; 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); const dme = num(V.cdiSrc) === 1 ? num(V.nav2Dme) : num(V.nav1Dme);
@@ -287,7 +302,7 @@ export default function CitPFD({ xp }) {
<g transform="translate(96 90)"><SpeedTape ias={ias} mach={mach} bug={num(V.apSpdBug)} alt={alt} /></g> <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> <text x={120} y={78} fontSize="14" fill="#9aa6ad" textAnchor="middle">KIAS</text>
{/* AOA index (#manual p22) */} {/* AOA index (#manual p22) */}
<g transform="translate(48 600)"><AoaIndex alpha={num(V.aoa)} /></g> <g transform="translate(48 600)"><AoaIndex alpha={aoa} /></g>
{/* altitude tape (#20,#21) + baro (#12,#17) */} {/* 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> <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) */} {/* VSI (#13,#14) */}