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(
{s % 20 === 0 && {s}}
,
);
}
return (
{/* low-speed awareness: red below Vso, amber Vso→Vs1 */}
{/* Vmo/Mmo barber pole: overspeed band from the top down to the Vmo line */}
{/* Vfe flap-limit marker */}
{marks}
{/* airspeed trend vector (#3): magenta line from the index up/down */}
{Math.abs(trendKt) > 1 && (
0 ? 8 : -8)} 75,${mid - trendKt * pxkt + (trendKt > 0 ? 8 : -8)}`} fill="#d24bd2" />
)}
{/* selected-speed bug (magenta) */}
{bug > 20 && }
{/* current readout box */}
{Math.round(ias)}
{mach >= 0.4 && M{mach.toFixed(2).slice(1)}}
);
}
// ── AOA index (manual p22): normalised 0 (zero-lift) … 1.0 (stall); the
// pilot keeps AOA below 0.6 (30% margin). alpha≈14° ≈ stall.
function AoaIndex({ alpha }) {
const n = clamp(alpha / 14, 0, 1.05);
const H = 120, y = (v) => H - v / 1.05 * H;
return (
AOA
);
}
// ── altitude tape + VSI ───────────────────────────────────────────────────────
function AltTape({ alt, bug, vs, baro, std, baroHpa, minOn, minFt, raBaro }) {
const H = 560, mid = H / 2, pxft = 0.32; // px per foot
const top = alt + mid / pxft, marks = [];
for (let s = Math.ceil((alt - mid / pxft) / 100) * 100; s <= top; s += 100) {
const y = mid + (alt - s) * pxft;
marks.push(
{s % 200 === 0 && {s}}
,
);
}
const baroTxt = std ? 'STD' : baroHpa ? `${Math.round(num(baro) * 33.8639)}` : num(baro).toFixed(2);
const minY = clamp(mid + (alt - minFt) * pxft, 6, H - 6);
return (
{marks}
{/* altitude trend (green, 6 s projection of VSI) */}
{/* minimums bug (cyan) */}
{minOn && }
{/* selected-altitude bug (magenta) */}
{/* current readout box */}
{Math.round(alt)}
{/* baro setting */}
{std ? 'STD' : `${baroTxt}${baroHpa ? '' : ''}`}
{minOn && {raBaro ? 'RA' : 'BARO'} {Math.round(minFt)}}
);
}
function VSI({ vs }) {
const H = 480, mid = H / 2;
// non-linear-ish: ±2000 fpm across the scale
const y = (fpm) => mid - clamp(fpm, -2500, 2500) / 2500 * (H / 2 - 10);
const ticks = [0, 500, 1000, 2000];
return (
{ticks.map((t) => (
{t > 0 && {t / 1000}}
{t > 0 && {t / 1000}}
))}
{Math.abs(vs) > 100 && 0 ? 14 : H - 4} fontSize="14" fill="#13e000" textAnchor="middle" fontWeight="700">{Math.round(vs / 50) * 50}}
);
}
// ── attitude ball ─────────────────────────────────────────────────────────────
function Attitude({ pitch, roll, slip, fdP, fdR, fdOn }) {
const R = 196, cx = 0, cy = 0;
const ladder = [];
for (let p = -90; p <= 90; p += 10) {
if (p === 0) continue;
const y = p * PXDEG;
const w = p % 20 === 0 ? 70 : 36;
ladder.push(
{p % 20 === 0 && <>
{Math.abs(p)}
{Math.abs(p)}
>}
,
);
}
// bank scale arc marks (top)
const bankMarks = [-60, -45, -30, -20, -10, 0, 10, 20, 30, 45, 60].map((b) => {
const a = (b - 90) * Math.PI / 180, r1 = R, r2 = b % 30 === 0 || b === 0 ? R - 16 : R - 10;
return ;
});
return (
{/* sky / ground, rotated by roll then pitched */}
{ladder}
{/* fixed bank pointer + arc */}
{bankMarks}
{/* slip/skid trapezoid below the bank pointer */}
{/* fixed aircraft reference (yellow) */}
{/* flight-director command bars (magenta V-bars) — #19 lateral / #22 vertical */}
{fdOn && (
)}
);
}
// ── HSI (rotating compass with CDI + bearing pointers) ─────────────────────────
function HSI({ hdg, trk, crs, hdgBug, cdi, toFrom, brg1, brg2, srcLabel, 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();
if (d % 30 === 0) {
const rt = R - 30, lbl = d === 0 ? 'N' : d === 90 ? 'E' : d === 180 ? 'S' : d === 270 ? 'W' : d / 10;
card.push({lbl});
}
}
const ptr = (deg, color, dbl) => {
const a = (deg - hdg) * Math.PI / 180; // 0 = up
const x = Math.sin(a), y = -Math.cos(a);
return (
{/* arrow head */}
{dbl && }
);
};
return (
{card}
{/* heading bug (magenta) */}
{/* course pointer + CDI deviation (#5 + #15) — FMS magenta / VOR green */}
{/* CDI bar */}
{[-2, -1, 1, 2].map((d) => )}
{toFrom !== 0 && 0 ? '0,-14 -8,2 8,2' : '0,14 -8,-2 8,-2'} fill={srcColor} />}
{/* bearing pointers — #6 secondary NAV (cyan circle = BRG1, white diamond = BRG2) */}
{brg1 != null && ptr(brg1, '#19c3e0', false)}
{brg2 != null && ptr(brg2, '#cfd6dc', true)}
{/* fixed lubber line + aircraft */}
{String(Math.round(mod360(hdg))).padStart(3, '0')}
{srcLabel}
);
}
export default function CitPFD({ xp, 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 (
{/* bezel buttons — Nav Source Selector (p24, sits under the PFD), MINIMUMS
rotary (#8), RA/BARO (#9), STD (#11), BARO SET (#12), CRS, HDG */}
NAV SRC
MINIMUMS
{min.on ? min.ft : '– – –'}
BARO SET
{std ? 'STD' : baro.toFixed(2)}
CRS
HDG
);
}