0ceb1dede3
- Nav Source Selector (p24) now fully drives the PFD: shared brg1/brg2 state (App ↔ RMU ↔ PFD). RMU has both pointer knobs — ◯ blue (OFF/VOR1/ADF1/FMS1) and ◇ white (OFF/VOR2/ADF2/FMS2). The PFD resolves each to a magnetic bearing (VOR bearing, ADF relative+heading, GPS bearing) and the HSI legend reflects the selected sources. - PFD FMA / AFCS mode annunciation bar across the top (lateral · AP/FD · vertical) reading the per-mode *_status datarefs — active green, armed white, matching the real Primus PFD. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
425 lines
24 KiB
React
425 lines
24 KiB
React
import React, { useRef } from 'react';
|
||
import { num } from '../../api/useXplane.js';
|
||
import { useEased, useEasedAngle } from '../../api/ease.js';
|
||
|
||
// ============================================================================
|
||
// Cessna Citation X — Primary Flight Display (Honeywell Primus 2000).
|
||
// Built line-for-line against the X-Plane Citation X manual (pages 30-31):
|
||
// 1 Attitude · 2 Airspeed scale · 3 Airspeed trend · 4 Heading bug
|
||
// 5 Desired course (CRS) · 6 Secondary NAV (bearing pointers) · 7 Desired hdg
|
||
// 8 Minimums · 9 RA/BARO · 10 HSI · 11 STD · 12 BARO SET · 13/14 VSI
|
||
// 15 CDI · 16 DME · 17 Altimeter setting · 18 Radar altimeter
|
||
// 19 FD lateral bar · 20 Altitude trend · 21 Altimeter scale · 22 FD vertical bar
|
||
// All values are live X-Plane datarefs streamed by the bridge (no Lua needed).
|
||
// ============================================================================
|
||
|
||
const PXDEG = 7; // pitch ladder px per degree
|
||
const clamp = (v, a, b) => Math.max(a, Math.min(b, v));
|
||
const mod360 = (d) => ((d % 360) + 360) % 360;
|
||
const hz2mhz = (hz) => (num(hz) / 100).toFixed(2);
|
||
|
||
// ── airspeed tape ───────────────────────────────────────────────────────────
|
||
// Citation X operating speeds (manual p80): Vso 115 · Vs1 136 · Vfe 180 ·
|
||
// Vmo 270 (SL-8000') / 350 (above) · Mmo 0.935.
|
||
const VSO = 115, VS1 = 136, VFE = 180;
|
||
function SpeedTape({ ias, mach, bug, alt, trendKt }) {
|
||
const H = 560, mid = H / 2, pxkt = 3.4; // 3.4 px per knot
|
||
const y = (s) => mid + (ias - s) * pxkt;
|
||
const vmo = alt > 8000 ? 350 : 270;
|
||
const top = ias + mid / pxkt, marks = [];
|
||
for (let s = Math.ceil((ias - mid / pxkt) / 10) * 10; s <= top; s += 10) {
|
||
if (s < 0) continue;
|
||
marks.push(
|
||
<g key={s}>
|
||
<line x1={70} y1={y(s)} x2={s % 20 === 0 ? 58 : 64} y2={y(s)} stroke="#cfd6dc" strokeWidth="1.4" />
|
||
{s % 20 === 0 && <text x={54} y={y(s) + 4} fontSize="17" fill="#e8edf1" textAnchor="end">{s}</text>}
|
||
</g>,
|
||
);
|
||
}
|
||
return (
|
||
<g>
|
||
<rect x={0} y={0} width={74} height={H} fill="#0c1116" opacity="0.82" />
|
||
<clipPath id="spdclip"><rect x={0} y={0} width={74} height={H} /></clipPath>
|
||
<g clipPath="url(#spdclip)">
|
||
{/* low-speed awareness: red below Vso, amber Vso→Vs1 */}
|
||
<rect x={0} y={y(VSO)} width={8} height={Math.max(0, H - y(VSO))} fill="#c0392b" />
|
||
<rect x={0} y={y(VS1)} width={8} height={Math.max(0, y(VSO) - y(VS1))} fill="#ffb000" />
|
||
{/* Vmo/Mmo barber pole: overspeed band from the top down to the Vmo line */}
|
||
<rect x={0} y={0} width={8} height={clamp(y(vmo), 0, H)} fill="url(#barber)" />
|
||
{/* Vfe flap-limit marker */}
|
||
<line x1={0} y1={y(VFE)} x2={12} y2={y(VFE)} stroke="#fff" strokeWidth="3" />
|
||
{marks}
|
||
{/* airspeed trend vector (#3): magenta line from the index up/down */}
|
||
{Math.abs(trendKt) > 1 && (
|
||
<g>
|
||
<line x1={71} y1={mid} x2={71} y2={mid - trendKt * pxkt} stroke="#d24bd2" strokeWidth="3" />
|
||
<polygon points={`71,${mid - trendKt * pxkt} 67,${mid - trendKt * pxkt + (trendKt > 0 ? 8 : -8)} 75,${mid - trendKt * pxkt + (trendKt > 0 ? 8 : -8)}`} fill="#d24bd2" />
|
||
</g>
|
||
)}
|
||
</g>
|
||
<defs>
|
||
<pattern id="barber" width="8" height="8" patternUnits="userSpaceOnUse" patternTransform="rotate(45)">
|
||
<rect width="8" height="8" fill="#fff" /><rect width="4" height="8" fill="#c0392b" />
|
||
</pattern>
|
||
</defs>
|
||
{/* selected-speed bug (magenta) */}
|
||
{bug > 20 && <polygon points={`74,${clamp(y(bug), 6, H - 6)} 64,${clamp(y(bug), 6, H - 6) - 7} 64,${clamp(y(bug), 6, H - 6) + 7}`} fill="#d24bd2" />}
|
||
{/* current readout box */}
|
||
<polygon points={`0,${mid - 20} 60,${mid - 20} 74,${mid} 60,${mid + 20} 0,${mid + 20}`} fill="#000" stroke="#cfd6dc" strokeWidth="1.4" />
|
||
<text x={50} y={mid + 8} fontSize="26" fill="#fff" textAnchor="end" fontWeight="700">{Math.round(ias)}</text>
|
||
{mach >= 0.4 && <text x={40} y={H - 6} fontSize="16" fill="#13e000" textAnchor="middle">M{mach.toFixed(2).slice(1)}</text>}
|
||
</g>
|
||
);
|
||
}
|
||
|
||
// ── AOA index (manual p22): normalised 0 (zero-lift) … 1.0 (stall); the
|
||
// pilot keeps AOA below 0.6 (30% margin). 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, srcColor }) {
|
||
const R = 150;
|
||
const card = [];
|
||
for (let d = 0; d < 360; d += 5) {
|
||
const a = (d - hdg - 90) * Math.PI / 180, r2 = d % 10 === 0 ? R - 14 : R - 8;
|
||
card.push(<line key={d} x1={Math.cos(a) * R} y1={Math.sin(a) * R} x2={Math.cos(a) * r2} y2={Math.sin(a) * r2} stroke="#cfd6dc" strokeWidth={d % 30 === 0 ? 1.8 : 1} />);
|
||
if (d % 30 === 0) {
|
||
const rt = R - 30, lbl = d === 0 ? 'N' : d === 90 ? 'E' : d === 180 ? 'S' : d === 270 ? 'W' : d / 10;
|
||
card.push(<text key={`t${d}`} x={Math.cos(a) * rt} y={Math.sin(a) * rt + 5} fontSize="14" fill="#e8edf1" textAnchor="middle">{lbl}</text>);
|
||
}
|
||
}
|
||
const ptr = (deg, color, dbl) => {
|
||
const a = (deg - hdg) * Math.PI / 180; // 0 = up
|
||
const x = Math.sin(a), y = -Math.cos(a);
|
||
return (
|
||
<g stroke={color} strokeWidth="2.5" fill="none">
|
||
<line x1={x * (R - 16)} y1={y * (R - 16)} x2={x * 40} y2={y * 40} />
|
||
{/* arrow head */}
|
||
<polygon points={`${x * (R - 16)},${y * (R - 16)} ${x * (R - 34) - y * 8},${y * (R - 34) + x * 8} ${x * (R - 34) + y * 8},${y * (R - 34) - x * 8}`} fill={color} />
|
||
{dbl && <line x1={x * -40} y1={y * -40} x2={x * -(R - 16)} y2={y * -(R - 16)} />}
|
||
</g>
|
||
);
|
||
};
|
||
return (
|
||
<g>
|
||
<circle cx={0} cy={0} r={R} fill="#0a0e12" stroke="#2a3138" strokeWidth="1.5" />
|
||
<g>{card}</g>
|
||
{/* heading bug (magenta) */}
|
||
<g transform={`rotate(${mod360(hdgBug - hdg)})`}><polygon points={`0,${-R} -9,${-R + 14} 9,${-R + 14}`} fill="#d24bd2" /></g>
|
||
{/* course pointer + CDI deviation (#5 + #15) — FMS magenta / VOR green */}
|
||
<g transform={`rotate(${mod360(crs - hdg)})`}>
|
||
<line x1={0} y1={-R + 18} x2={0} y2={-50} stroke={srcColor} strokeWidth="3" />
|
||
<polygon points={`0,${-R + 6} -8,${-R + 22} 8,${-R + 22}`} fill={srcColor} />
|
||
<line x1={0} y1={50} x2={0} y2={R - 18} stroke={srcColor} strokeWidth="3" />
|
||
{/* CDI bar */}
|
||
<line x1={clamp(cdi, -2, 2) * 30} y1={-46} x2={clamp(cdi, -2, 2) * 30} y2={46} stroke={srcColor} strokeWidth="3.5" />
|
||
{[-2, -1, 1, 2].map((d) => <circle key={d} cx={d * 30} cy={0} r="3" fill="none" stroke="#9aa6ad" strokeWidth="1.2" />)}
|
||
{toFrom !== 0 && <polygon points={toFrom > 0 ? '0,-14 -8,2 8,2' : '0,14 -8,-2 8,-2'} fill={srcColor} />}
|
||
</g>
|
||
{/* bearing pointers — #6 secondary NAV (cyan circle = BRG1, white diamond = BRG2) */}
|
||
{brg1 != null && ptr(brg1, '#19c3e0', false)}
|
||
{brg2 != null && ptr(brg2, '#cfd6dc', true)}
|
||
{/* fixed lubber line + aircraft */}
|
||
<polygon points={`0,${-R - 2} -8,${-R - 16} 8,${-R - 16}`} fill="#fff" />
|
||
<text x={0} y={-R - 22} fontSize="13" fill="#fff" textAnchor="middle" fontWeight="700">{String(Math.round(mod360(hdg))).padStart(3, '0')}</text>
|
||
<text x={0} y={6} fontSize="12" fill={srcColor} textAnchor="middle">{srcLabel}</text>
|
||
</g>
|
||
);
|
||
}
|
||
|
||
export default function CitPFD({ xp, navSrc }) {
|
||
const V = xp.values || {};
|
||
const bsrc = navSrc || { brg1: 'VOR1', brg2: 'VOR2' };
|
||
const [std, setStd] = React.useState(false);
|
||
const [raBaro, setRaBaro] = React.useState(false); // #9 RA/BARO minimums source
|
||
const [min, setMin] = React.useState({ on: false, ft: 200 });
|
||
const trend = useRef({ ias: 0, t: 0 });
|
||
|
||
// Smooth the moving symbology toward the live datarefs (frame-rate-independent
|
||
// easing) — the same rAF glide the G1000 uses, so a 10-20 Hz stream renders as
|
||
// fluid 60 fps motion instead of stepping.
|
||
const ias = useEased(num(V.airspeed), 0.10);
|
||
const alt = useEased(num(V.altitude), 0.12);
|
||
const vs = useEased(num(V.vspeed), 0.18);
|
||
const pitch = useEased(num(V.pitch), 0.07);
|
||
const roll = useEased(num(V.roll), 0.07);
|
||
const slip = useEased(num(V.slip), 0.12);
|
||
const hdg = useEasedAngle(num(V.heading), 0.08);
|
||
const crs = useEasedAngle(num(V.obsCrs), 0.10);
|
||
const hdgBug = useEasedAngle(num(V.apHdgBug), 0.10);
|
||
const cdi = useEased(num(V.hsiDef), 0.12);
|
||
const mach = useEased(num(V.mach), 0.2);
|
||
const aoa = useEased(num(V.aoa), 0.12);
|
||
const hdgRaw = num(V.heading);
|
||
const vor1e = useEasedAngle(num(V.nav1Brg), 0.12);
|
||
const vor2e = useEasedAngle(num(V.nav2Brg), 0.12);
|
||
const adf1e = useEasedAngle(mod360(hdgRaw + num(V.adf1Brg)), 0.12); // ADF relative → mag bearing
|
||
const adf2e = useEasedAngle(mod360(hdgRaw + num(V.adf2Brg)), 0.12);
|
||
const gpsBrgE = useEasedAngle(num(V.gpsBearing), 0.12);
|
||
const trk = num(V.track), toFrom = num(V.hsiToFrom);
|
||
const baro = num(V.baro, 29.92);
|
||
const radAlt = num(V.radioAlt, 99999);
|
||
const fdOn = num(V.apMode) >= 1 || num(V.apEngaged) > 0;
|
||
// bearing pointer source (Nav Source Selector): resolve each pointer to a
|
||
// magnetic bearing or null (no station / OFF). Pointer 1 = cyan ◯, 2 = white ◇.
|
||
const pickBrg = (sel) => {
|
||
if (sel === 'VOR1') return (num(V.nav1Dme) > 0 || num(V.nav1Brg) > 0) ? vor1e : null;
|
||
if (sel === 'VOR2') return (num(V.nav2Dme) > 0 || num(V.nav2Brg) > 0) ? vor2e : null;
|
||
if (sel === 'ADF1') return num(V.adf1Brg) ? adf1e : null;
|
||
if (sel === 'ADF2') return num(V.adf2Brg) ? adf2e : null;
|
||
if (sel === 'FMS1' || sel === 'FMS') return num(V.gpsBearing) ? gpsBrgE : null;
|
||
return null;
|
||
};
|
||
const brg1 = pickBrg(bsrc.brg1);
|
||
const brg2 = pickBrg(bsrc.brg2);
|
||
// FMA / AFCS mode annunciation (active = green, armed = white)
|
||
const st = (k) => num(V[k]);
|
||
const latM = st('aprStatus') ? ['LOC', st('aprStatus')] : (st('navStatus') || st('gpssStatus')) ? ['NAV', Math.max(st('navStatus'), st('gpssStatus'))]
|
||
: st('bcStatus') ? ['BC', st('bcStatus')] : st('hdgStatus') ? ['HDG', st('hdgStatus')] : ['ROLL', 2];
|
||
const vertM = st('gsStatus') ? ['GS', st('gsStatus')] : st('vnavStatus') ? ['VNAV', st('vnavStatus')]
|
||
: st('flcStatus') ? ['FLC', st('flcStatus')] : st('vsStatus') ? ['VS', st('vsStatus')]
|
||
: st('altStatus') ? ['ALT', st('altStatus')] : ['PITCH', 2];
|
||
const apTxt = num(V.apEngaged) > 0 || num(V.apMode) >= 2 ? 'AP' : fdOn ? 'FD' : '';
|
||
const fmaColor = (s) => (s >= 2 ? '#16e000' : '#fff');
|
||
// Nav source (Nav Source Selector, manual p24): 0 VOR1 · 1 VOR2 · 2 FMS.
|
||
// FMS course is magenta, VOR course is green (Honeywell convention).
|
||
const cdiSrc = num(V.cdiSrc);
|
||
const srcLabel = cdiSrc === 2 ? 'FMS1' : cdiSrc === 1 ? 'VOR2' : 'VOR1';
|
||
const srcColor = cdiSrc === 2 ? '#d24bd2' : '#13e000';
|
||
const cycleNav = () => xp.setDataref('cdiSrc', cdiSrc === 1 ? 0 : 1); // toggle VOR1↔VOR2
|
||
const setFms = () => xp.setDataref('cdiSrc', 2);
|
||
const dme = cdiSrc === 1 ? num(V.nav2Dme) : num(V.nav1Dme);
|
||
// airspeed trend vector (#3): smoothed acceleration projected 10 s ahead
|
||
const t = trend.current, nowS = (typeof performance !== 'undefined' ? performance.now() : Date.now()) / 1000;
|
||
const dt = Math.min(0.5, Math.max(0.001, nowS - (t.t || nowS)));
|
||
const rate = (ias - (t.ias != null ? t.ias : ias)) / dt; // kt/s
|
||
t.s = (t.s || 0) * 0.92 + rate * 0.08; t.ias = ias; t.t = nowS;
|
||
const trendKt = clamp(t.s * 10, -45, 45);
|
||
|
||
return (
|
||
<div className="cit-screen">
|
||
<svg className="cit-pfd" viewBox="0 0 800 940" preserveAspectRatio="xMidYMid meet">
|
||
<rect x={0} y={0} width={800} height={940} fill="#05080b" />
|
||
{/* FMA / AFCS mode annunciation bar (active green · armed white) */}
|
||
<g transform="translate(204 8)">
|
||
<rect x={0} y={0} width={392} height={26} fill="#0a0e12" stroke="#2a3138" />
|
||
<line x1={130} y1={2} x2={130} y2={24} stroke="#2a3138" /><line x1={262} y1={2} x2={262} y2={24} stroke="#2a3138" />
|
||
<text x={65} y={18} fontSize="14" fill={fmaColor(latM[1])} textAnchor="middle" fontWeight="700">{latM[0]}</text>
|
||
<text x={196} y={18} fontSize="14" fill="#16e000" textAnchor="middle" fontWeight="700">{apTxt}</text>
|
||
<text x={328} y={18} fontSize="14" fill={fmaColor(vertM[1])} textAnchor="middle" fontWeight="700">{vertM[0]}</text>
|
||
</g>
|
||
{/* attitude */}
|
||
<g transform="translate(400 270)"><Attitude pitch={pitch} roll={roll} slip={slip} fdP={num(V.fdPitch)} fdR={num(V.fdRoll)} fdOn={fdOn} /></g>
|
||
{/* speed tape (#2,#3) */}
|
||
<g transform="translate(96 90)"><SpeedTape ias={ias} mach={mach} bug={num(V.apSpdBug)} alt={alt} trendKt={trendKt} /></g>
|
||
<text x={120} y={78} fontSize="14" fill="#9aa6ad" textAnchor="middle">KIAS</text>
|
||
{/* AOA index (#manual p22) */}
|
||
<g transform="translate(48 600)"><AoaIndex alpha={aoa} /></g>
|
||
{/* altitude tape (#20,#21) + baro (#12,#17) */}
|
||
<g transform="translate(584 90)"><AltTape alt={alt} bug={num(V.apAltBug)} vs={vs} baro={baro} std={std} baroHpa={false} minOn={min.on} minFt={min.ft} raBaro={raBaro} /></g>
|
||
{/* VSI (#13,#14) */}
|
||
<g transform="translate(716 130)"><VSI vs={vs} /></g>
|
||
{/* HSI (#10) */}
|
||
<g transform="translate(400 690)"><HSI hdg={hdg} trk={trk} crs={crs} hdgBug={hdgBug} cdi={cdi} toFrom={toFrom} brg1={brg1} brg2={brg2} srcLabel={srcLabel} srcColor={srcColor} /></g>
|
||
|
||
{/* CRS / HDG digital (#5,#7) */}
|
||
<g fontSize="15" fontWeight="700">
|
||
<text x={20} y={560} fill="#19c3e0">CRS</text>
|
||
<text x={20} y={580} fill="#19c3e0" fontSize="20">{String(Math.round(mod360(crs))).padStart(3, '0')}</text>
|
||
<text x={20} y={642} fill="#d24bd2">HDG</text>
|
||
<text x={20} y={662} fill="#d24bd2" fontSize="20">{String(Math.round(mod360(hdgBug))).padStart(3, '0')}</text>
|
||
</g>
|
||
{/* secondary NAV legend (#6) — reflects the Nav Source Selector */}
|
||
<g fontSize="13">
|
||
{bsrc.brg1 !== 'OFF' && <><circle cx={28} cy={726} r="6" fill="none" stroke="#19c3e0" strokeWidth="2" />
|
||
<text x={42} y={731} fill="#19c3e0">{bsrc.brg1}</text></>}
|
||
{bsrc.brg2 !== 'OFF' && <><rect x={22} y={744} width={12} height={12} fill="none" stroke="#cfd6dc" strokeWidth="2" transform="rotate(45 28 750)" />
|
||
<text x={42} y={755} fill="#cfd6dc">{bsrc.brg2}</text></>}
|
||
</g>
|
||
{/* DME (#16) */}
|
||
{dme > 0 && <text x={760} y={620} fontSize="16" fill="#13e000" textAnchor="end">{dme.toFixed(1)}NM</text>}
|
||
{/* radar altimeter (#18) — only within 2500 ft AGL */}
|
||
{radAlt < 2500 && <text x={400} y={420} fontSize="22" fill="#13e000" textAnchor="middle" fontWeight="700">{Math.round(radAlt)}</text>}
|
||
{radAlt < 2500 && <text x={400} y={436} fontSize="11" fill="#9aa6ad" textAnchor="middle">RA</text>}
|
||
{/* TCAS label */}
|
||
<text x={690} y={840} fontSize="13" fill="#9aa6ad">TCAS</text>
|
||
<text x={760} y={84} fontSize="13" fill="#9aa6ad" textAnchor="end">FT</text>
|
||
</svg>
|
||
|
||
{/* bezel buttons — Nav Source Selector (p24, sits under the PFD), MINIMUMS
|
||
rotary (#8), RA/BARO (#9), STD (#11), BARO SET (#12), CRS, HDG */}
|
||
<div className="cit-bezel">
|
||
<div className="cit-bz-group">
|
||
<span className="cit-bz-lbl">NAV SRC</span>
|
||
<button className={`cit-bz-btn ${cdiSrc !== 2 ? 'on' : ''}`} onClick={cycleNav}>{cdiSrc === 1 ? 'VOR2' : 'VOR1'}</button>
|
||
<button className={`cit-bz-btn ${cdiSrc === 2 ? 'on' : ''}`} onClick={setFms}>FMS</button>
|
||
</div>
|
||
<div className="cit-bz-group">
|
||
<span className="cit-bz-lbl">MINIMUMS</span>
|
||
<button className="cit-bz-knob" onClick={() => setMin((m) => ({ ...m, on: true, ft: m.ft - 50 }))}>‹</button>
|
||
<span className="cit-bz-val">{min.on ? min.ft : '– – –'}</span>
|
||
<button className="cit-bz-knob" onClick={() => setMin((m) => ({ ...m, on: true, ft: m.ft + 50 }))}>›</button>
|
||
<button className={`cit-bz-btn ${min.on ? 'on' : ''}`} onClick={() => setMin((m) => ({ ...m, on: !m.on }))}>MIN</button>
|
||
</div>
|
||
<button className={`cit-bz-btn ${raBaro ? 'on' : ''}`} onClick={() => setRaBaro((v) => !v)}>RA/BARO</button>
|
||
<button className={`cit-bz-btn ${std ? 'on' : ''}`} onClick={() => setStd((v) => !v)}>STD</button>
|
||
<div className="cit-bz-group">
|
||
<span className="cit-bz-lbl">BARO SET</span>
|
||
<button className="cit-bz-knob" onClick={() => xp.command('pfd_baro_down')}>‹</button>
|
||
<span className="cit-bz-val">{std ? 'STD' : baro.toFixed(2)}</span>
|
||
<button className="cit-bz-knob" onClick={() => xp.command('pfd_baro_up')}>›</button>
|
||
</div>
|
||
<div className="cit-bz-group">
|
||
<span className="cit-bz-lbl">CRS</span>
|
||
<button className="cit-bz-knob" onClick={() => xp.command('pfd_crs_down')}>‹</button>
|
||
<button className="cit-bz-knob" onClick={() => xp.command('pfd_crs_up')}>›</button>
|
||
</div>
|
||
<div className="cit-bz-group">
|
||
<span className="cit-bz-lbl">HDG</span>
|
||
<button className="cit-bz-knob" onClick={() => xp.command('pfd_hdg_down')}>‹</button>
|
||
<button className="cit-bz-knob" onClick={() => xp.command('pfd_hdg_sync')}>SYNC</button>
|
||
<button className="cit-bz-knob" onClick={() => xp.command('pfd_hdg_up')}>›</button>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
);
|
||
}
|