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 (
{/* FMA / AFCS mode annunciation bar (active green · armed white) */} {latM[0]} {apTxt} {vertM[0]} {/* attitude */} {/* speed tape (#2,#3) */} KIAS {/* AOA index (#manual p22) */} {/* altitude tape (#20,#21) + baro (#12,#17) */} {/* VSI (#13,#14) */} {/* HSI (#10) */} {/* CRS / HDG digital (#5,#7) */} CRS {String(Math.round(mod360(crs))).padStart(3, '0')} HDG {String(Math.round(mod360(hdgBug))).padStart(3, '0')} {/* secondary NAV legend (#6) — reflects the Nav Source Selector */} {bsrc.brg1 !== 'OFF' && <> {bsrc.brg1}} {bsrc.brg2 !== 'OFF' && <> {bsrc.brg2}} {/* DME (#16) */} {dme > 0 && {dme.toFixed(1)}NM} {/* radar altimeter (#18) — only within 2500 ft AGL */} {radAlt < 2500 && {Math.round(radAlt)}} {radAlt < 2500 && RA} {/* TCAS label */} TCAS FT {/* 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
); }