Files
xplane-cockpit/web/src/components/citation/CitMFD.jsx
T
karim 6756acab4a Citation: combined PFD+MFD view, hardware AP look, FMS build-out, fluid easing
- CitDuo: PFD + MFD side-by-side on one tablet screen (new 'PFD+MFD' tab,
  first in the Citation profile) — the two pilot DU-870 tubes at once.
- Autopilot restyled to the real Primus FGC: machined dark bezel w/ corner
  screws, engraved square keys with green annunciator triangles (lit when
  active), ridged pitch thumbwheel.
- FMS more complete per the FMS manual: DEP/ARR now does the two-step
  procedure→transition pick (NO TRANS / RWxx / named transitions), VNAV split
  into CLB/CRZ/DES pages (trans-alt, speed/alt limits, cruise alt, target
  speed, VPA) via PREV/NEXT, and a new PROG page (TO/DEST distance-to-go + ETE
  at GS). Page keys: FPLN/LEGS/DEP-ARR/DIR-INTC/VNAV/PROG/MENU.
- Fluidity: Citation PFD/MFD/EICAS now use the same rAF time-constant easing as
  the G1000 (useEased/useEasedAngle) for attitude, speed/alt/VS tapes, HSI,
  compass, map ownship and N1/ITT gauges — smooth 60 fps instead of stepping.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-04 12:33:04 +02:00

202 lines
12 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, { useState, useEffect, useRef } from 'react';
import { num } from '../../api/useXplane.js';
import { useEased, useEasedAngle } from '../../api/ease.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); }, []);
// smooth ownship + compass (same rAF glide as the G1000 map)
const lat = useEased(num(V.lat), 0.14);
const lon = useEased(num(V.lon), 0.14);
const hdg = useEasedAngle(num(V.heading), 0.10);
const 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>
);
}