6756acab4a
- 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>
202 lines
12 KiB
React
202 lines
12 KiB
React
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), dφ = toRad(bLat - aLat), dλ = toRad(bLon - aLon);
|
||
const h = Math.sin(dφ / 2) ** 2 + Math.cos(φ1) * Math.cos(φ2) * Math.sin(dλ / 2) ** 2;
|
||
const dist = 3440.065 * 2 * Math.atan2(Math.sqrt(h), Math.sqrt(1 - h));
|
||
const y = Math.sin(dλ) * Math.cos(φ2);
|
||
const x = Math.cos(φ1) * Math.sin(φ2) - Math.sin(φ1) * Math.cos(φ2) * Math.cos(dλ);
|
||
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>
|
||
);
|
||
}
|