Files
xplane-cockpit/web/src/components/citation/CitPFD.jsx
T
karim 0ceb1dede3 Citation: ADF/VOR/FMS bearing pointers wired through + PFD FMA mode bar
- 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>
2026-06-04 20:10:13 +02:00

425 lines
24 KiB
React
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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>
);
}