diff --git a/web/src/App.jsx b/web/src/App.jsx
index 8fca01a..a5d2ced 100644
--- a/web/src/App.jsx
+++ b/web/src/App.jsx
@@ -14,6 +14,7 @@ import AudioPanel from './components/AudioPanel.jsx';
import KAP140 from './components/KAP140.jsx';
import CitPFD from './components/citation/CitPFD.jsx';
import CitMFD from './components/citation/CitMFD.jsx';
+import CitDuo from './components/citation/CitDuo.jsx';
import CitEICAS from './components/citation/CitEICAS.jsx';
import CitAP from './components/citation/CitAP.jsx';
import CitRMU from './components/citation/CitRMU.jsx';
@@ -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',
eicas: 'M5 4v14M9 4v14M5 11h4M13 7h5M13 11h5M13 15h5',
rmu: 'M4 5h14v12H4zM7 8h8M7 11h8M7 14h4',
+ duo: 'M3 5h7v12H3zM12 5h7v12h-7z',
};
function Icon({ name }) {
return (
@@ -54,9 +56,9 @@ const PROFILES = {
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' },
+ { id: 'duo', label: 'PFD+MFD' }, { id: 'pfd', label: 'PFD' }, { id: 'mfd', label: 'MFD' },
+ { id: 'eicas', label: 'EICAS' }, { id: 'fms', label: 'CDU/FMS' }, { id: 'ap', label: 'Autopilot' },
+ { id: 'rmu', label: 'Radios' }, { id: 'map', label: 'Map' },
],
},
ga: {
@@ -234,6 +236,7 @@ export default function App() {
)}
{/* ---- Cessna Citation X suite (Honeywell Primus 2000) ---- */}
+ {profile === 'citation' && tab === 'duo' && }
{profile === 'citation' && tab === 'pfd' && }
{profile === 'citation' && tab === 'mfd' && }
{profile === 'citation' && tab === 'eicas' && }
diff --git a/web/src/citation.css b/web/src/citation.css
index 7334489..8b32a93 100644
--- a/web/src/citation.css
+++ b/web/src/citation.css
@@ -57,46 +57,81 @@
.cit-bz-knob:hover { background: #2e3740; }
/* ============================================================================
- AUTOPILOT (Flight Guidance Controller)
+ AUTOPILOT — Honeywell Primus 2000 Flight Guidance Controller (hardware look)
============================================================================ */
-.citap-screen { gap: 10px; }
-.citap-refs { display: flex; gap: 26px; color: #cdd6dd; font-family: 'Roboto Mono',monospace; }
+.citap-screen { gap: 14px; }
+.citap-refs { display: flex; gap: 30px; color: #cdd6dd; font-family: 'Roboto Mono',monospace; }
.citap-refs div { text-align: center; }
.citap-refs span { display: block; font-size: 10px; color: #8b97a0; letter-spacing: .08em; }
.citap-refs b { font-size: 22px; color: #d24bd2; }
.citap-fma {
- display: flex; gap: 0; background: #05080b; border: 1px solid #2a3138; border-radius: 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;
}
.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 {
- 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);
+ position: relative; display: flex; align-items: stretch; gap: 10px; padding: 20px 26px;
+ background: linear-gradient(#3a4047 0%,#23282e 8%,#1a1e23 92%,#2b3137 100%);
+ border: 1px solid #0c0f12; border-radius: 10px;
+ box-shadow: inset 0 1px 0 rgba(255,255,255,.08), inset 0 0 0 1px #0c0f12, 0 8px 26px rgba(0,0,0,.6);
}
-.citap-col { display: flex; flex-direction: column; gap: 10px; justify-content: flex-start; }
-.citap-master { margin-left: 6px; }
+.citap-panel::before, .citap-panel::after {
+ content: ''; position: absolute; width: 7px; height: 7px; border-radius: 50%;
+ background: radial-gradient(circle at 35% 35%,#5a626a,#1b1f24); top: 7px;
+}
+.citap-panel::before { left: 8px; } .citap-panel::after { right: 8px; }
+.citap-col { display: flex; flex-direction: column; gap: 9px; justify-content: flex-start; }
+.citap-master { margin-left: 8px; border-left: 1px solid #0c0f12; padding-left: 14px; }
+
+/* engraved square key with a green annunciator triangle (lit when active) */
.citap-btn {
- position: relative; 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;
+ position: relative; width: 82px; padding: 9px 8px 9px 20px; text-align: left;
+ font-size: 12px; font-weight: 700; letter-spacing: .06em; color: #e4e9ee;
+ background: linear-gradient(#33393f,#1c2025); border: 1px solid #0e1114;
+ border-radius: 4px; cursor: pointer; font-family: 'Roboto Mono',monospace;
+ box-shadow: inset 0 1px 0 rgba(255,255,255,.10), inset 0 -2px 3px rgba(0,0,0,.5), 0 1px 2px rgba(0,0,0,.6);
+ text-shadow: 0 1px 1px #000;
}
-.citap-btn .citap-arrow { position: absolute; left: 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-btn .citap-arrow {
+ position: absolute; left: 7px; top: 50%; transform: translateY(-50%);
+ width: 0; height: 0; border-top: 5px solid transparent; border-bottom: 5px solid transparent;
+ border-left: 7px solid #2b3137; font-size: 0; line-height: 0; color: transparent;
+}
+.citap-btn:hover { background: linear-gradient(#3b424a,#23282e); }
+.citap-btn:active { box-shadow: inset 0 2px 4px rgba(0,0,0,.7); }
+.citap-btn.active .citap-arrow { border-left-color: #1fff4e; filter: drop-shadow(0 0 4px #16e000); }
+.citap-btn.active { color: #fff; }
+.citap-btn.armed .citap-arrow { border-left-color: #fff; }
+.citap-btn.armed { color: #fff; }
+.citap-btn.dim { opacity: .4; cursor: default; }
+
+/* pitch wheel: NOSE UP/DN labels + a ridged thumbwheel */
+.citap-wheel { display: flex; flex-direction: column; align-items: center; gap: 4px; justify-content: center; padding: 0 10px; }
+.citap-wlbl { font-size: 8px; color: #aeb8bf; letter-spacing: .14em; }
+.citap-wbtn { width: 30px; padding: 3px 0; font-size: 12px; color: #cdd6dd; background: #21262b; border: 1px solid #0e1114; border-radius: 3px; cursor: pointer; }
+.citap-wheelface {
+ width: 30px; height: 64px; border-radius: 5px; border: 1px solid #0e1114;
+ background: repeating-linear-gradient(#0d0f12,#0d0f12 2px,#3a424a 3px,#22272c 5px,#0d0f12 7px);
+ box-shadow: inset 2px 0 4px rgba(0,0,0,.7), inset -2px 0 4px rgba(0,0,0,.7);
+}
+.citap-foot { font-size: 11px; color: #8b97a0; max-width: 660px; text-align: center; line-height: 1.5; }
.citap-foot b { color: #7fd4ff; }
+/* ---- combined PFD + MFD (one tablet screen) ---- */
+.cit-duo { display: flex; width: 100%; height: 100%; background: #05080b; }
+.cit-duo-half { flex: 1; min-width: 0; height: 100%; display: flex; }
+.cit-duo .cit-screen { padding: 8px; gap: 8px; }
+.cit-duo .cit-bezel { padding: 5px 7px; gap: 5px; }
+.cit-duo .cit-bz-btn, .cit-duo .cit-sk { min-width: 0; padding: 5px 7px; font-size: 10px; }
+.cit-duo .cit-bz-group { padding: 1px 5px; }
+.cit-duo .cit-bz-val { min-width: 38px; font-size: 11px; }
+.cit-duo .cit-bz-knob { width: 22px; padding: 4px 0; }
+@media (max-width: 900px) { .cit-duo { flex-direction: column; } }
+
/* ============================================================================
RADIO MANAGEMENT UNIT + Nav source selector
============================================================================ */
diff --git a/web/src/components/CDU.jsx b/web/src/components/CDU.jsx
index a7361df..b2c12f6 100644
--- a/web/src/components/CDU.jsx
+++ b/web/src/components/CDU.jsx
@@ -23,8 +23,9 @@ function brng(a, b) {
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 VNAV_PAGES = ['CLB', 'CRZ', 'DES'];
export default function CDU({ xp, vnav: vnavCfg, onVnav }) {
const { flightPlan, fp, exportMsg, command } = xp;
@@ -42,10 +43,15 @@ export default function CDU({ xp, vnav: vnavCfg, onVnav }) {
const [procs, setProcs] = useState(null);
const [cat, setCat] = useState('sids'); // sids | stars | approaches
const [procPage, setProcPage] = useState(0);
+ const [selProc, setSelProc] = useState(null); // procedure awaiting a transition pick
// VNAV perf (FMS init values; VPA feeds the shared descent profile)
const cfg = vnavCfg || { fpa: 3, offsetNm: 0, enabled: true };
const [crzAlt, setCrzAlt] = useState('');
const [tgtSpd, setTgtSpd] = useState('');
+ const [transAlt, setTransAlt] = useState('18000'); // CLB transition altitude (manual default)
+ const [clbLim, setClbLim] = useState('250/10000'); // 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)
const [plans, setPlans] = useState(null);
@@ -128,21 +134,37 @@ export default function CDU({ xp, vnav: vnavCfg, onVnav }) {
return;
}
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') {
+ // step 1: a procedure is selected → if it has transitions, pick one; else load
+ if (selProc) {
+ if (r === 0) return loadProc(selProc.name, ''); // NO TRANS / direct
+ const tr = (selProc.transitions || [])[r - 1];
+ if (tr) return loadProc(selProc.name, tr);
+ return;
+ }
const list = (procs && procs[cat]) || [];
const p = list[procPage * 5 + r];
- if (p) 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;
}
if (page === 'dir') { if (scr) return directTo(scr); return; }
if (page === 'vnav') {
- 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 === 2 && scr && onVnav) { const v = parseFloat(scr); if (v >= 2 && v <= 6) onVnav((c) => ({ ...c, fpa: v })); setScr(''); return; }
+ 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 === 'R' && r === 0 && scr) { setTgtSpd(scr.replace('/', '')); 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;
}
+ if (page === 'prog') return;
if (page === 'menu') {
if (side === 'L' && r === 0) return openLoad();
if (side === 'L' && r === 1) return exec();
@@ -183,14 +205,45 @@ export default function CDU({ xp, vnav: vnavCfg, onVnav }) {
: (<>{row.id}{row.type} {row.dtk == null ? '---' : `${String(row.dtk).padStart(3, '0')}°`} {row.orig ? 'ORIG' : row.d.toFixed(1)} >)}
))});
} else if (page === 'deparr') {
- title = `${dest || '----'} ${cat === 'sids' ? 'DEPART' : cat === 'stars' ? 'ARRIVAL' : 'APPROACH'}`; pageNo = `${procPage + 1}/${procPages}`;
- const shown = procList.slice(procPage * 5, procPage * 5 + 5);
+ const kind = cat === 'sids' ? 'DEPART' : cat === 'stars' ? 'ARRIVAL' : 'APPROACH';
+ if (selProc) {
+ title = `${selProc.name} TRANS`; pageNo = `1/1`;
+ body = (
+
+
<NO TRANS direct
+ {(selProc.transitions || []).slice(0, 4).map((t) =>
<{t}
)}
+
Transition wählen (LSK) · oder NO TRANS (1L)
+
+ );
+ } else {
+ title = `${dest || '----'} ${kind}`; pageNo = `${procPage + 1}/${procPages}`;
+ const shown = procList.slice(procPage * 5, procPage * 5 + 5);
+ body = (
+
+
SID> STAR> APPR>
+ {!procs &&
{dest ? 'loading…' : 'set DEST on FPLN'}
}
+ {procs && shown.length === 0 &&
none
}
+ {shown.map((p, i) =>
<{p.name} {p.transitions?.length ? {p.transitions.length} TR> : null}
)}
+
+ );
+ }
+ } 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 = (
-
-
SID> STAR> APPR>
- {!procs &&
{dest ? 'loading…' : 'set DEST on FPLN'}
}
- {procs && shown.length === 0 &&
none
}
- {shown.map((p, i) =>
<{p.name} {p.transitions?.length ? {p.transitions.length} TR : null}
)}
+
+
TO {actW?.id || '----'}
+
DTG {dA.toFixed(1)} NM
+
ETE {ete(dA)}
+
DEST {destW?.id || '----'}
+
DEST DTG {dD.toFixed(0)} NM
+
DEST ETE {ete(dD)}
+
GS {gs} KT · TAS {Math.round(num(xp.values.tas))} · SAT {Math.round(num(xp.values.oat))}°C
);
} else if (page === 'dir') {
@@ -203,15 +256,34 @@ export default function CDU({ xp, vnav: vnavCfg, onVnav }) {
);
} else if (page === 'vnav') {
- title = 'ACT VNAV';
- body = (
-
-
CRZ ALT {crzAlt || '-----'}
-
TGT SPD {tgtSpd || '---'}
-
FPA / VPA {(cfg.fpa || 3).toFixed(1)}°
-
CRZ ALT: LSK1L · TGT SPD: LSK1R · VPA: LSK3R
-
- );
+ title = `ACT VNAV ${VNAV_PAGES[vnavPage]}`; pageNo = `${vnavPage + 1}/3`;
+ if (vnavPage === 0) { // CLB (manual p21-22)
+ body = (
+
+
TRANS ALT {transAlt || '18000'}
+
TGT SPD {tgtSpd || '290/.74'}
+
SPD/ALT LIMIT {clbLim}
+
TRANS ALT: 1L · SPD/ALT LIMIT: 2L · NEXT→CRZ
+
+ );
+ } else if (vnavPage === 1) { // CRZ (manual p23)
+ body = (
+
+
CRZ ALT {crzAlt ? `FL${crzAlt}` : '-----'}
+
TGT SPD {tgtSpd || '.80'}
+
CRZ ALT: 1L (e.g. 280=FL280) · TGT SPD: 1R
+
+ );
+ } else { // DES (manual p23-24)
+ body = (
+
+
TGT SPD {tgtSpd || '/200'}
+
VPA {(cfg.fpa || 3).toFixed(1)}°
+
SPD/ALT LIMIT {desLim}
+
TGT SPD: 1L · VPA: 1R (2.0-6.0°) · SPD/ALT: 2L
+
+ );
+ }
} else if (page === 'menu') {
title = plans ? 'CO ROUTE LIST' : 'ROUTE MENU';
body = plans ? (
@@ -247,13 +319,13 @@ export default function CDU({ xp, vnav: vnavCfg, onVnav }) {
{/* page keys */}
{PAGE_KEYS.map(([id, lbl]) => (
- { setPage(id); setScr(''); setDel(false); setPlans(null); }}>{lbl}
+ { setPage(id); setScr(''); setDel(false); setPlans(null); setSelProc(null); }}>{lbl}
))}
-
{ if (page === 'legs') setLegPage((p) => Math.max(0, p - 1)); else if (page === 'deparr') setProcPage((p) => Math.max(0, p - 1)); }}>PREV
-
{ if (page === 'legs') setLegPage((p) => Math.min(legPages - 1, p + 1)); else if (page === 'deparr') setProcPage((p) => Math.min(procPages - 1, p + 1)); }}>NEXT
+
{ 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
+
{ 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
{ setDel((d) => !d); setScr(''); }}>DEL
CLR
EXEC
diff --git a/web/src/components/citation/CitDuo.jsx b/web/src/components/citation/CitDuo.jsx
new file mode 100644
index 0000000..88cadb0
--- /dev/null
+++ b/web/src/components/citation/CitDuo.jsx
@@ -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 (
+
+ );
+}
diff --git a/web/src/components/citation/CitEICAS.jsx b/web/src/components/citation/CitEICAS.jsx
index 4f7a064..e17b4c6 100644
--- a/web/src/components/citation/CitEICAS.jsx
+++ b/web/src/components/citation/CitEICAS.jsx
@@ -1,5 +1,6 @@
import React, { useState } from 'react';
import { num } from '../../api/useXplane.js';
+import { useEased } from '../../api/ease.js';
// ============================================================================
// Citation X — Engine Indicating & Crew Alerting System (EICAS).
@@ -48,8 +49,8 @@ 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 n1 = [useEased(arr(V.n1, 0), 0.16), useEased(arr(V.n1, 1), 0.16)];
+ const itt = [useEased(arr(V.itt, 0), 0.2), useEased(arr(V.itt, 1), 0.2)];
const oilT = [arr(V.oilTemp, 0), arr(V.oilTemp, 1)];
const oilP = [arr(V.oilPress, 0), arr(V.oilPress, 1)];
const ff = [arr(V.fuelFlow, 0), arr(V.fuelFlow, 1)];
diff --git a/web/src/components/citation/CitMFD.jsx b/web/src/components/citation/CitMFD.jsx
index a6afb28..059e56a 100644
--- a/web/src/components/citation/CitMFD.jsx
+++ b/web/src/components/citation/CitMFD.jsx
@@ -1,5 +1,6 @@
import React, { useState, useEffect, useRef } from 'react';
import { num } from '../../api/useXplane.js';
+import { useEased, useEasedAngle } from '../../api/ease.js';
// ============================================================================
// Citation X — Multi-Function Display (Honeywell Primus 2000 arc map).
@@ -35,7 +36,11 @@ export default function CitMFD({ xp }) {
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);
+ // 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
const W = 760, H = 760, cx = W / 2, cy = 600, R = 470; // compass radius
const pxPerNm = R / rng;
diff --git a/web/src/components/citation/CitPFD.jsx b/web/src/components/citation/CitPFD.jsx
index c08035e..4a0cd77 100644
--- a/web/src/components/citation/CitPFD.jsx
+++ b/web/src/components/citation/CitPFD.jsx
@@ -1,5 +1,6 @@
import React, { useRef } from 'react';
import { num } from '../../api/useXplane.js';
+import { useEased, useEasedAngle } from '../../api/ease.js';
// ============================================================================
// Cessna Citation X — Primary Flight Display (Honeywell Primus 2000).
@@ -264,16 +265,30 @@ export default function CitPFD({ xp }) {
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);
+ // Smooth the moving symbology toward the live datarefs (frame-rate-independent
+ // easing) — the same rAF glide the G1000 uses, so a 10-20 Hz stream renders as
+ // fluid 60 fps motion instead of stepping.
+ const ias = useEased(num(V.airspeed), 0.10);
+ const alt = useEased(num(V.altitude), 0.12);
+ const vs = useEased(num(V.vspeed), 0.18);
+ const pitch = useEased(num(V.pitch), 0.07);
+ const roll = useEased(num(V.roll), 0.07);
+ const slip = useEased(num(V.slip), 0.12);
+ const hdg = useEasedAngle(num(V.heading), 0.08);
+ const crs = useEasedAngle(num(V.obsCrs), 0.10);
+ const hdgBug = useEasedAngle(num(V.apHdgBug), 0.10);
+ const cdi = useEased(num(V.hsiDef), 0.12);
+ const mach = useEased(num(V.mach), 0.2);
+ const aoa = useEased(num(V.aoa), 0.12);
+ const 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 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 brg1 = (num(V.nav1Dme) > 0 || num(V.nav1Brg) > 0) ? brg1e : 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 dme = num(V.cdiSrc) === 1 ? num(V.nav2Dme) : num(V.nav1Dme);
@@ -287,7 +302,7 @@ export default function CitPFD({ xp }) {
KIAS
{/* AOA index (#manual p22) */}
-
+
{/* altitude tape (#20,#21) + baro (#12,#17) */}
{/* VSI (#13,#14) */}