Citation X cockpit profile: full Primus 2000 suite (PFD/MFD/EICAS/AP/RMU)

Add a switchable cockpit-profile selector (Garmin G1000 / Cessna Citation X /
GA steam) and recreate the Citation X Honeywell Primus 2000 avionics line-for-
line from the X-Plane Citation X + FMS manuals:

- CitPFD: attitude w/ FD command bars, speed tape (Vmo barber-pole, Vfe, low-
  speed red/amber bands), AOA index, altitude tape + trend, VSI, round HSI with
  CDI/course pointer + VOR/ADF bearing pointers, radar altimeter, minimums,
  STD/BARO/CRS/HDG bezel.
- CitEICAS: twin FAN%/ITT bar gauges, OIL °C/PSI, FUEL (flow/qty PPH·LBS),
  ELECTRICAL, HYDRAULICS, slat chevron, STAB trim, FLAPS, CAS message stack,
  softkeys NORM/FUEL-HYD/ELEC/CTRL-POS/ENG + control-position overlay.
- CitMFD: Honeywell heading-up arc map, FMS route (magenta active/white future),
  TCAS, terrain/WX, range arc, ETE/SAT/TAS/GSPD block, clock + ET/FT timer,
  V-SPEEDS reference card, MFD-setup overlays (TRAFFIC/TERRAIN/APTS/VOR).
- CitAP: HDG/NAV/APP/BC · ALT/VNAV/BANK/STBY · FLC/C-O/VS · pitch wheel ·
  AP/YD/M-TRIM/PFD-SEL, FMA bar + lamps from per-mode *_status datarefs.
- CitRMU: COM/NAV active+standby tuning, transponder, ADF, TCAS range/mode,
  IDENT + Nav Source Selector (NAV1/2/FMS, VOR/ADF/FMS bearing source).

Integration: all avionics stream live via the X-Plane Web API (new datarefs for
N1/N2/ITT, radar-alt, AOA, hydraulics, trim, flaps/slats/gear, control
positions, ADF, mach, yaw-damper); the existing fms-sync.lua drives the
Citation's built-in FMS (aircraft-agnostic XPLM FMS SDK). Demo seeds added so
every panel renders offline. Verified headless via Playwright (no console
errors; G1000/GA profiles unaffected).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
2026-06-04 12:09:55 +02:00
parent aa64959eea
commit b05ffedbc1
11 changed files with 1298 additions and 17 deletions
+196
View File
@@ -0,0 +1,196 @@
import React, { useState, useEffect, useRef } from 'react';
import { num } from '../../api/useXplane.js';
// ============================================================================
// Citation X — Multi-Function Display (Honeywell Primus 2000 arc map).
// Built against the manual (pages 32-33):
// 1 Heading bug · 2 Heading · 3 Compass arc · 4 FMS source · 5 future leg (white)
// 6 active leg (magenta) · 7 range arc · 8 ETE/SAT/TAS/GSPD group · 9 RNG
// 10 V-SPEEDS · 11 EICAS SYS · 12 ET/FT timer · 13 MFD setup (TRAFFIC/TERRAIN/
// APTS/VOR) · 14 PFD setup · 15 RTN · 16 WX status · 17 ownship · 18 airport
// 19 navaid · 20 digital heading bug
// ============================================================================
const RNGS = [10, 20, 40, 80, 160];
const mod360 = (d) => ((d % 360) + 360) % 360;
const toRad = (d) => (d * Math.PI) / 180;
// great-circle distance (NM) + initial bearing (deg) from a→b
function geo(aLat, aLon, bLat, bLon) {
const φ1 = toRad(aLat), φ2 = toRad(bLat), = toRad(bLat - aLat), = toRad(bLon - aLon);
const h = Math.sin( / 2) ** 2 + Math.cos(φ1) * Math.cos(φ2) * Math.sin( / 2) ** 2;
const dist = 3440.065 * 2 * Math.atan2(Math.sqrt(h), Math.sqrt(1 - h));
const y = Math.sin() * Math.cos(φ2);
const x = Math.cos(φ1) * Math.sin(φ2) - Math.sin(φ1) * Math.cos(φ2) * Math.cos();
return { dist, brg: mod360((Math.atan2(y, x) * 180) / Math.PI) };
}
export default function CitMFD({ xp }) {
const V = xp.values || {};
const fp = xp.flightPlan || { waypoints: [] };
const [rng, setRng] = useState(40);
const [ov, setOv] = useState({ traffic: true, terrain: false, apts: true, vor: true });
const [setup, setSetup] = useState(null); // null | 'mfd' | 'eicas' | 'pfd'
const [vspd, setVspd] = useState(false);
const [et, setEt] = useState(0);
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);
// 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;
const project = (d, brg) => { // heading-up
const rel = toRad(brg - hdg);
return [cx + Math.sin(rel) * d * pxPerNm, cy - Math.cos(rel) * d * pxPerNm];
};
// build route polyline from waypoints relative to ownship
const wps = (fp.waypoints || []).map((w) => {
if (!isFinite(w.lat) || !isFinite(w.lon)) return null;
const g = geo(lat, lon, w.lat, w.lon);
const [x, y] = project(g.dist, g.brg);
return { ...w, x, y, dist: g.dist };
}).filter(Boolean);
const active = num(fp.activeLeg ?? 1);
// compass arc ticks
const ticks = [];
for (let i = -60; i <= 60; i += 5) {
const a = toRad(i), x1 = cx + Math.sin(a) * R, y1 = cy - Math.cos(a) * R;
const len = i % 30 === 0 ? 20 : i % 10 === 0 ? 14 : 8;
const x2 = cx + Math.sin(a) * (R - len), y2 = cy - Math.cos(a) * (R - len);
ticks.push(<line key={i} x1={x1} y1={y1} x2={x2} y2={y2} stroke="#cfd6dc" strokeWidth={i % 30 === 0 ? 1.8 : 1} />);
if (i % 30 === 0) {
const h = mod360(hdg + i), lx = cx + Math.sin(a) * (R - 36), ly = cy - Math.cos(a) * (R - 36);
ticks.push(<text key={`l${i}`} x={lx} y={ly + 5} fontSize="16" fill="#e8edf1" textAnchor="middle">{String(Math.round(h / 10) % 36).padStart(2, '0')}</text>);
}
}
const gs = Math.round(num(V.groundspeed) * 1.94384);
const tas = Math.round(num(V.tas));
const sat = Math.round(num(V.oat));
// ETE to destination (last wp) at current GS
const destDist = wps.length ? wps[wps.length - 1].dist : 0;
const eteMin = gs > 20 ? destDist / gs * 60 : 0;
const fmt = (s) => `${String(Math.floor(s / 60)).padStart(2, '0')}:${String(Math.floor(s % 60)).padStart(2, '0')}`;
const now = new Date();
const SK = ({ label, on, onClick }) => <button className={`cit-sk ${on ? 'on' : ''}`} onClick={onClick}>{label}</button>;
return (
<div className="cit-screen">
<svg className="cit-mfd" viewBox="0 0 760 760" preserveAspectRatio="xMidYMid meet">
<rect x={0} y={0} width={760} height={760} fill="#04070a" />
<clipPath id="mfdclip"><rect x={0} y={70} width={760} height={690} /></clipPath>
{/* heading box (#2,#20) + FMS source (#4) */}
<text x={24} y={30} fontSize="14" fill="#19c3e0">HDG</text>
<text x={24} y={52} fontSize="22" fill="#d24bd2">{String(Math.round(mod360(num(V.apHdgBug)))).padStart(3, '0')}</text>
<rect x={300} y={12} width={160} height={30} fill="none" stroke="#2a3138" />
<text x={380} y={33} fontSize="18" fill="#13e000" textAnchor="middle">{String(Math.round(mod360(hdg))).padStart(3, '0')}°</text>
<text x={720} y={30} fontSize="16" fill="#d24bd2" textAnchor="end">FMS1</text>
<g clipPath="url(#mfdclip)">
{/* compass arc (#3) */}
{ticks}
{/* range arc (#7) at mid range */}
<path d={`M ${cx + Math.sin(toRad(-60)) * R / 2} ${cy - Math.cos(toRad(-60)) * R / 2} A ${R / 2} ${R / 2} 0 0 1 ${cx + Math.sin(toRad(60)) * R / 2} ${cy - Math.cos(toRad(60)) * R / 2}`} fill="none" stroke="#3a4148" strokeDasharray="3 6" />
<text x={cx + R / 2 - 8} y={cy - R / 2} fontSize="13" fill="#9aa6ad">{rng / 2}</text>
{/* NEXRAD weather (#16 WX) */}
{ov.terrain && (V.wxCells || []).map((c, i) => {
const g = geo(lat, lon, c.lat, c.lon); const [x, y] = project(g.dist, g.brg);
return <circle key={i} cx={x} cy={y} r={c.r * pxPerNm} fill={['#0a5', '#aa0', '#a00'][c.lvl - 1]} opacity="0.4" />;
})}
{/* flight-plan route (#5 white future, #6 magenta active) */}
{wps.length > 1 && wps.map((w, i) => i === 0 ? null : (
<line key={`leg${i}`} x1={wps[i - 1].x} y1={wps[i - 1].y} x2={w.x} y2={w.y}
stroke={i === active ? '#d24bd2' : '#e8edf1'} strokeWidth={i === active ? 3 : 2} />
))}
{wps.map((w, i) => (
<g key={`wp${i}`}>
{(w.type === 'APT') ? (ov.apts && <circle cx={w.x} cy={w.y} r="6" fill="none" stroke="#13e000" strokeWidth="2" />)
: (ov.vor && <polygon points={`${w.x},${w.y - 6} ${w.x + 6},${w.y} ${w.x},${w.y + 6} ${w.x - 6},${w.y}`} fill="none" stroke="#13e000" strokeWidth="1.6" />)}
<text x={w.x + 9} y={w.y + 4} fontSize="12" fill="#e8edf1">{w.id}</text>
</g>
))}
{/* TCAS traffic (#13 TRAFFIC) */}
{ov.traffic && (V.traffic || []).map((t, i) => {
const g = geo(lat, lon, t.lat, t.lon); const [x, y] = project(g.dist, g.brg);
const col = t.thr === 2 ? '#ff3b30' : t.thr === 1 ? '#ffb000' : '#19c3e0';
return <g key={i}><polygon points={`${x},${y - 7} ${x + 7},${y} ${x},${y + 7} ${x - 7},${y}`} fill={col} /><text x={x + 10} y={y - 6} fontSize="10" fill={col}>{t.relAlt > 0 ? '+' : ''}{t.relAlt}</text></g>;
})}
{/* ownship (#17) */}
<g transform={`translate(${cx} ${cy})`}>
<polygon points="0,-14 9,12 0,5 -9,12" fill="#fff" stroke="#000" strokeWidth="0.8" />
</g>
{/* heading bug on arc (#1) */}
{(() => { const rel = mod360(num(V.apHdgBug) - hdg); const a = toRad(rel > 180 ? rel - 360 : rel); if (Math.abs(rel > 180 ? rel - 360 : rel) > 60) return null; const x = cx + Math.sin(a) * R, y = cy - Math.cos(a) * R; return <polygon points={`${x},${y} ${x - 7},${y - 12} ${x + 7},${y - 12}`} fill="#d24bd2" />; })()}
</g>
{/* data group (#8) bottom-right */}
<g transform="translate(560 470)">
<rect x={0} y={0} width={184} height={120} fill="#070b0f" stroke="#2a3138" />
<text x={92} y={20} fontSize="12" fill="#9aa6ad" textAnchor="middle">NM {rng}</text>
<text x={10} y={44} fontSize="13" fill="#9aa6ad">ETE</text><text x={174} y={44} fontSize="14" fill="#13e000" textAnchor="end">{eteMin > 0 ? fmt(eteMin * 60) : ' '}</text>
<text x={10} y={66} fontSize="13" fill="#9aa6ad">SAT</text><text x={174} y={66} fontSize="14" fill="#13e000" textAnchor="end">{sat}°C</text>
<text x={10} y={88} fontSize="13" fill="#9aa6ad">TAS</text><text x={174} y={88} fontSize="14" fill="#13e000" textAnchor="end">{tas}</text>
<text x={10} y={110} fontSize="13" fill="#9aa6ad">GSPD</text><text x={174} y={110} fontSize="14" fill="#13e000" textAnchor="end">{gs}</text>
</g>
{/* V-SPEEDS reference card (#10) — Citation X operating speeds, manual p80 */}
{vspd && (
<g transform="translate(250 120)">
<rect x={0} y={0} width={260} height={300} fill="#070b0f" stroke="#19c3e0" />
<text x={130} y={26} fontSize="16" fill="#19c3e0" textAnchor="middle">V-SPEEDS · CITATION X</text>
{[['Vr (rotate)', '145'], ['Vfe (flaps)', '180'], ['Vmo SL-8000', '270'], ['Vmo >8000', '350'],
['Mmo', '0.935'], ['Vle/Vlo gear', '210'], ['Vref landing', '132'], ['Vso stall (ldg)', '115'],
['Vs1 stall (clean)', '136']].map(([k, v], i) => (
<g key={k} transform={`translate(0 ${52 + i * 26})`}>
<text x={16} y={0} fontSize="14" fill="#cfd6dc">{k}</text>
<text x={244} y={0} fontSize="14" fill="#13e000" textAnchor="end">{v}</text>
</g>
))}
</g>
)}
{/* clock / ET + WX status (#12,#16) bottom-left */}
<g transform="translate(20 470)">
<rect x={0} y={0} width={150} height={120} fill="#070b0f" stroke="#2a3138" />
<text x={75} y={20} fontSize="13" fill="#13e000" textAnchor="middle">{now.toTimeString().slice(0, 8)}</text>
<text x={75} y={38} fontSize="11" fill="#9aa6ad" textAnchor="middle">CLOCK</text>
<text x={75} y={62} fontSize="15" fill="#13e000" textAnchor="middle">ET {fmt(et)}</text>
<text x={10} y={92} fontSize="12" fill={ov.terrain ? '#13e000' : '#5a6168'}>WX</text>
<text x={10} y={110} fontSize="11" fill="#9aa6ad">T0.0 G100%</text>
</g>
</svg>
<div className="cit-bezel cit-mfd-sk">
{setup === 'mfd' ? (
<>
<SK label="TRAFFIC" on={ov.traffic} onClick={() => setOv((o) => ({ ...o, traffic: !o.traffic }))} />
<SK label="TERRAIN" on={ov.terrain} onClick={() => setOv((o) => ({ ...o, terrain: !o.terrain }))} />
<SK label="APTS" on={ov.apts} onClick={() => setOv((o) => ({ ...o, apts: !o.apts }))} />
<SK label="VOR" on={ov.vor} onClick={() => setOv((o) => ({ ...o, vor: !o.vor }))} />
<SK label="RTN" onClick={() => setSetup(null)} />
</>
) : (
<>
<button className="cit-sk" onClick={() => setSetup('pfd')}>PFD SETUP</button>
<button className="cit-sk" onClick={() => setSetup('mfd')}>MFD SETUP</button>
<button className="cit-sk" onClick={() => { etRun.current = !etRun.current; }}>ET/FT</button>
<button className="cit-sk" onClick={() => setSetup('eicas')}>EICAS SYS</button>
<button className={`cit-sk ${vspd ? 'on' : ''}`} onClick={() => setVspd((v) => !v)}>V SPEEDS</button>
<div className="cit-bz-group">
<span className="cit-bz-lbl">RNG</span>
<button className="cit-bz-knob" onClick={() => setRng((r) => RNGS[Math.max(0, RNGS.indexOf(r) - 1)])}></button>
<span className="cit-bz-val">{rng}</span>
<button className="cit-bz-knob" onClick={() => setRng((r) => RNGS[Math.min(RNGS.length - 1, RNGS.indexOf(r) + 1)])}>+</button>
</div>
</>
)}
</div>
</div>
);
}