Files
xplane-cockpit/web/src/components/MFD.jsx
T
karim 474a35c6e3 MFD PROFILE: vertical situation view (altitude vs distance along the plan)
The PROFILE softkey was dead. Now it overlays a vertical profile at the bottom of
the MFD map: own aircraft at the left, upcoming waypoints with their target
altitudes, the magenta planned descent path, and an altitude grid — pure geometry
from the active flight plan + current altitude.

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

331 lines
18 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 } from 'react';
import { num } from '../api/useXplane.js';
import MapView from './MapView.jsx';
import Nearest from './Nearest.jsx';
import FplPage from './FplPage.jsx';
const arr = (v, i = 0, d = 0) => (Array.isArray(v) ? num(v[i], d) : num(v, d));
const KG_PER_GAL = 2.72; // avgas
const navF = (v) => (num(v) / 100).toFixed(2);
const comF = (v) => (num(v) / 100).toFixed(3);
// Active flight-plan leg: distance / desired track / ETE to the active waypoint
// (great-circle from the aircraft), for the MFD nav data bar. Mirrors the PFD's
// activeNav so the two displays agree.
const R_NM = 3440.065, D2R = Math.PI / 180, R2D = 180 / Math.PI;
function legNav(V, fp) {
const wps = fp?.waypoints || [];
const ai = Math.max(1, Math.min(wps.length - 1, fp?.activeLeg ?? 1));
const wp = wps[ai];
const lat = num(V.lat), lon = num(V.lon);
if (!wp || (!lat && !lon)) return null;
const dLat = (wp.lat - lat) * D2R, dLon = (wp.lon - lon) * D2R;
const a = Math.sin(dLat / 2) ** 2 + Math.cos(lat * D2R) * Math.cos(wp.lat * D2R) * Math.sin(dLon / 2) ** 2;
const dist = 2 * R_NM * Math.asin(Math.min(1, Math.sqrt(a)));
const y = Math.sin(dLon) * Math.cos(wp.lat * D2R);
const x = Math.cos(lat * D2R) * Math.sin(wp.lat * D2R) - Math.sin(lat * D2R) * Math.cos(wp.lat * D2R) * Math.cos(dLon);
const dtk = (Math.atan2(y, x) * R2D + 360) % 360;
const gs = num(V.groundspeed) * 1.94384;
return { id: wp.id, dist, dtk, ete: gs > 20 ? (dist / gs) * 3600 : null };
}
const fmtEte = (s) => {
if (s == null) return '__:__';
const m = Math.floor(s / 60), ss = Math.round(s % 60);
return m < 60 ? `${m}:${String(ss).padStart(2, '0')}` : `${Math.floor(m / 60)}+${String(m % 60).padStart(2, '0')}`;
};
// G1000 MFD — full-width NAV/COM bar on top, the engine instrument strip (EIS)
// down the left as real bar gauges, and the moving map (X-Plane nav data) with
// G1000 chrome (compass rose, range, NORTH UP, mode) filling the rest.
const MFD_PAGES = [{ id: 'map', name: 'MAP' }, { id: 'fpl', name: 'FPL' }, { id: 'nrst', name: 'NRST' }];
export default function MFD({ values: V, flightPlan, fp, mapMode, page = 'map', onCycle, xp, vnav, onVnav }) {
const [rangeNm, setRangeNm] = useState(8);
const idx = Math.max(0, MFD_PAGES.findIndex((p) => p.id === page));
return (
<div className="mfd-g1000">
<MfdTopBar V={V} fp={flightPlan} />
<div className="mfd-body">
<EisStrip V={V} />
<div className="mfd-map">
{/* MapView stays mounted (keeps tiles warm) but is hidden under NRST */}
<div style={{ position: 'absolute', inset: 0, visibility: page === 'map' ? 'visible' : 'hidden' }}>
<MapView values={V} flightPlan={flightPlan} fp={fp} hud={false}
mapMode={mapMode} dcltr={mapMode?.dcltr || 0} rangeNm={num(V.uiMapRange) || undefined}
terrain={xp?.terrain} rose onView={({ rangeNm }) => setRangeNm(rangeNm)} />
<MapChrome V={V} rangeNm={rangeNm} />
</div>
{page === 'map' && mapMode?.profile && <VertProfile V={V} fp={flightPlan} />}
{page === 'nrst' && <Nearest xp={xp} full />}
{page === 'fpl' && xp && <FplPage xp={xp} full vnav={vnav} onVnav={onVnav} />}
{/* page-group indicator (bottom-right), like the real G1000 — selected
by the FMS knob; tappable as a touch fallback. */}
<button className="mfd-pageind" onClick={() => onCycle && onCycle(1)} title="Seite (FMS-Knopf)">
<span>{MFD_PAGES[idx].name}</span>
{MFD_PAGES.map((p, i) => <em key={p.id} className={i === idx ? 'on' : ''} />)}
</button>
</div>
</div>
</div>
);
}
/* ---------------- top NAV/COM bar ---------------- */
function MfdTopBar({ V, fp }) {
const gs = Math.round(num(V.groundspeed) * 1.94384);
const trk = String(Math.round(num(V.track)) % 360).padStart(3, '0');
const leg = legNav(V, fp);
const dtk = leg ? `${String(Math.round(leg.dtk) % 360).padStart(3, '0')}°` : '___°';
const swap = (x, y) => <text x={x} y={y} fill="#0ff" fontSize="16" textAnchor="middle"></text>;
return (
<svg className="mfd-topbar" viewBox="0 0 1000 70" preserveAspectRatio="none" fontFamily="monospace">
<rect x="0" y="0" width="1000" height="70" fill="#000" />
{[300, 660].map((x) => <line key={x} x1={x} y1="2" x2={x} y2="68" stroke="#333" strokeWidth="1.5" />)}
<line x1="0" y1="70" x2="1000" y2="70" stroke="#3a3a3a" strokeWidth="2" />
{/* NAV1 / NAV2 — standby LEFT (cyan, boxed), active RIGHT (white) per manual */}
<text x="10" y="27" fill="#fff" fontSize="13">NAV1</text>
<rect x="50" y="11" width="80" height="21" fill="none" stroke="#0ff" strokeWidth="1.3" />
<text x="126" y="27" fill="#0ff" fontSize="17" textAnchor="end">{navF(V.nav1Sb)}</text>
{swap(150, 27)}
<text x="174" y="27" fill="#fff" fontSize="17">{navF(V.nav1)}</text>
<text x="10" y="58" fill="#fff" fontSize="13">NAV2</text>
<text x="126" y="58" fill="#0ff" fontSize="17" textAnchor="end">{navF(V.nav2Sb)}</text>
<text x="174" y="58" fill="#fff" fontSize="17">{navF(V.nav2)}</text>
{/* centre: GS/DTK/TRK/ETE + active mode line */}
<text x="312" y="27" fill="#fff" fontSize="13">GS</text>
<text x="350" y="27" fill="#e040fb" fontSize="15" fontWeight="bold">{gs}</text>
<text x="378" y="27" fill="#0c9" fontSize="11">KT</text>
<text x="410" y="27" fill="#fff" fontSize="13">DTK</text>
<text x="448" y="27" fill="#e040fb" fontSize="15" fontWeight="bold">{dtk}</text>
<text x="520" y="27" fill="#fff" fontSize="13">TRK</text>
<text x="560" y="27" fill="#e040fb" fontSize="15" fontWeight="bold">{trk}°</text>
<text x="610" y="27" fill="#fff" fontSize="13">ETE</text>
<text x="648" y="27" fill="#fff" fontSize="15">{fmtEte(leg?.ete)}</text>
{/* active leg (centre): → waypoint + distance, or no-flight-plan note */}
{leg ? (
<g>
<text x="412" y="58" fill="#e040fb" fontSize="16"></text>
<text x="432" y="58" fill="#fff" fontSize="16" fontWeight="bold">{leg.id}</text>
<text x="520" y="58" fill="#0ff" fontSize="15">{leg.dist.toFixed(1)}</text>
<text x="566" y="58" fill="#0c9" fontSize="11">NM</text>
</g>
) : (
<text x="480" y="58" fill="#777" fontSize="14" textAnchor="middle">NO ACTIVE WAYPOINT</text>
)}
{/* COM1 / COM2 */}
<text x="690" y="27" fill="#0f0" fontSize="17">{comF(V.com1)}</text>
{swap(818, 27)}
<rect x="846" y="11" width="92" height="21" fill="none" stroke="#0ff" strokeWidth="1.3" />
<text x="936" y="27" fill="#0ff" fontSize="17" textAnchor="end">{comF(V.com1Sb)}</text>
<text x="994" y="27" fill="#fff" fontSize="12" textAnchor="end">COM1</text>
<text x="690" y="58" fill="#fff" fontSize="17">{comF(V.com2)}</text>
<text x="936" y="58" fill="#fff" fontSize="17" textAnchor="end">{comF(V.com2Sb)}</text>
<text x="994" y="58" fill="#fff" fontSize="12" textAnchor="end">COM2</text>
</svg>
);
}
/* ---------------- vertical profile (PROFILE softkey) ---------------- */
// Altitude-vs-distance view along the active flight plan: the aircraft at the
// left, upcoming waypoints with their target altitudes, and the planned descent
// path between them. Pure geometry from the plan + current altitude.
function VertProfile({ V, fp }) {
const R = 3440.065, rad = (d) => (d * Math.PI) / 180;
const dist = (a, b) => {
const dLat = rad(b.lat - a.lat), dLon = rad(b.lon - a.lon);
const s = Math.sin(dLat / 2) ** 2 + Math.cos(rad(a.lat)) * Math.cos(rad(b.lat)) * Math.sin(dLon / 2) ** 2;
return 2 * R * Math.asin(Math.min(1, Math.sqrt(s)));
};
const wps = fp?.waypoints || [];
const ai = Math.max(1, Math.min(wps.length - 1, fp?.activeLeg ?? 1));
const alt = num(V.altitude);
const pts = []; let cum = 0, prev = { lat: num(V.lat), lon: num(V.lon) };
for (let i = ai; i < wps.length; i++) { cum += dist(prev, wps[i]); prev = wps[i]; pts.push({ d: cum, alt: num(wps[i].alt) || null, id: wps[i].id }); }
const maxD = Math.max(10, cum);
const altMax = Math.max(alt, ...pts.map((p) => p.alt || 0), 3000) * 1.15;
const W = 1000, H = 200, padL = 46, padR = 12, padT = 12, padB = 22;
const x = (d) => padL + (d / maxD) * (W - padL - padR);
const y = (a) => (H - padB) - (a / altMax) * (H - padT - padB);
const altPts = pts.filter((p) => p.alt != null);
const path = [`${x(0)},${y(alt)}`, ...altPts.map((p) => `${x(p.d)},${y(p.alt)}`)].join(' ');
const grid = [0, altMax / 2, altMax].map((a) => Math.round(a / 500) * 500);
return (
<div className="vprof">
<svg viewBox={`0 0 ${W} ${H}`} preserveAspectRatio="none">
<rect x="0" y="0" width={W} height={H} fill="#05080b" />
{grid.map((a) => (
<g key={a}>
<line x1={padL} y1={y(a)} x2={W - padR} y2={y(a)} stroke="#1b2730" strokeWidth="1" />
<text x={padL - 5} y={y(a) + 4} fill="#6f808d" fontSize="11" textAnchor="end" fontFamily="monospace">{Math.round(a / 100) * 100}</text>
</g>
))}
{/* planned vertical path through waypoint target altitudes */}
{altPts.length > 0 && <polyline points={path} fill="none" stroke="#ff20ff" strokeWidth="2.5" />}
{/* waypoints */}
{pts.map((p, i) => (
<g key={p.id + i}>
<line x1={x(p.d)} y1={padT} x2={x(p.d)} y2={H - padB} stroke="#222d36" strokeWidth="1" />
{p.alt != null && <rect x={x(p.d) - 3} y={y(p.alt) - 3} width="6" height="6" fill="#4fa8ff" />}
<text x={x(p.d)} y={H - padB + 14} fill="#9fb3c0" fontSize="11" textAnchor="middle" fontFamily="monospace">{p.id}</text>
{p.alt != null && <text x={x(p.d)} y={y(p.alt) - 7} fill="#4fa8ff" fontSize="10" textAnchor="middle" fontFamily="monospace">{p.alt}</text>}
</g>
))}
{/* own aircraft at the left edge */}
<polygon points={`${x(0) - 7},${y(alt)} ${x(0) + 7},${y(alt) - 5} ${x(0) + 7},${y(alt) + 5}`} fill="#ffce00" />
<text x={x(0) + 10} y={y(alt) - 6} fill="#ffce00" fontSize="11" fontFamily="monospace">{Math.round(alt)}</text>
</svg>
</div>
);
}
/* ---------------- engine instrument strip (EIS) ---------------- */
function EisStrip({ V }) {
const rpm = arr(V.engRpm);
const ffGph = (arr(V.fuelFlow) * 3600) / KG_PER_GAL;
const oilPsi = arr(V.oilPress);
// X-Plane's temperature indicator datarefs may already honor the user's unit
// (°F) despite the "_deg_C" name. Auto-detect: only convert if it still looks
// like Celsius, so we don't double-convert (which pegged the gauges red).
const oilT = arr(V.oilTemp), egtT = arr(V.egt);
const oilF = oilT > 150 ? oilT : oilT * 9 / 5 + 32;
const egtF = egtT > 900 ? egtT : egtT * 9 / 5 + 32;
const fuelL = arr(V.fuelQty, 0) / KG_PER_GAL;
const fuelR = arr(V.fuelQty, 1) / KG_PER_GAL;
const voltsM = arr(V.volts, 0, 28); // main bus
const voltsE = arr(V.volts, 1, voltsM); // essential bus (falls back to main)
const ampsM = arr(V.genAmps, 0); // alternator (M)
const ampsS = arr(V.amps, 0); // battery (S)
const engHrs = num(V.engHrs) / 3600;
return (
<svg className="eis-svg" viewBox="0 0 190 540" preserveAspectRatio="xMidYMin meet" fontFamily="monospace">
<rect x="0" y="0" width="190" height="540" fill="#0a0a0a" />
<RpmArc rpm={rpm} />
<Bar y={132} label="FFLOW GPH" val={ffGph.toFixed(1)} min={0} max={20} value={ffGph}
zones={[{ from: 0, to: 17, c: '#0c0' }, { from: 17, to: 20, c: '#c00' }]} />
<Bar y={170} label="OIL PSI" val={Math.round(oilPsi)} min={0} max={100} value={oilPsi}
zones={[{ from: 0, to: 20, c: '#c00' }, { from: 20, to: 100, c: '#0c0' }]} />
<Bar y={208} label="OIL °F" val={Math.round(oilF)} min={75} max={250} value={oilF}
zones={[{ from: 100, to: 245, c: '#0c0' }]} />
<Bar y={246} label="EGT °F" val={Math.round(egtF)} min={800} max={1650} value={egtF} zones={[]} />
<Bar y={284} label="VAC" min={0} max={10} value={5}
zones={[{ from: 4.5, to: 5.5, c: '#0c0' }]} />
<FuelBar y={330} left={fuelL} right={fuelR} />
{/* fuel totalizer (SYSTEM → DEC/INC/RST FUEL): pilot-set remaining + used */}
{(() => {
const rem = num(V.fuelTot) / KG_PER_GAL;
const used = Math.max(0, (num(V.fuelMax) - num(V.fuelTot)) / KG_PER_GAL);
return (<>
<text x="8" y="392" fill="#39d3c0" fontSize="11">CALC</text>
<text x="118" y="392" fill="#fff" fontSize="14" textAnchor="end">{rem.toFixed(1)}</text>
<text x="124" y="392" fill="#7f8c97" fontSize="10">GAL</text>
<text x="182" y="392" fill="#9aa" fontSize="10" textAnchor="end">USED {used.toFixed(0)}</text>
</>);
})()}
<text x="8" y="412" fill="#39d3c0" fontSize="12">ENG</text>
<text x="182" y="412" fill="#fff" fontSize="14" textAnchor="end">{engHrs.toFixed(1)} HRS</text>
<text x="95" y="438" fill="#39d3c0" fontSize="12" textAnchor="middle"> ELECTRICAL </text>
<text x="20" y="462" fill="#fff" fontSize="12">M</text>
<text x="95" y="462" fill="#39d3c0" fontSize="12" textAnchor="middle">BUS</text>
<text x="170" y="462" fill="#fff" fontSize="12" textAnchor="end">E</text>
<text x="18" y="482" fill="#fff" fontSize="15">{voltsM.toFixed(1)}</text>
<text x="95" y="482" fill="#39d3c0" fontSize="11" textAnchor="middle">VOLTS</text>
<text x="172" y="482" fill="#fff" fontSize="15" textAnchor="end">{voltsE.toFixed(1)}</text>
<text x="20" y="506" fill="#fff" fontSize="12">M</text>
<text x="95" y="506" fill="#39d3c0" fontSize="12" textAnchor="middle">BATT</text>
<text x="170" y="506" fill="#fff" fontSize="12" textAnchor="end">S</text>
<text x="18" y="526" fill="#fff" fontSize="15">{ampsM >= 0 ? '+' : ''}{ampsM.toFixed(1)}</text>
<text x="95" y="526" fill="#39d3c0" fontSize="11" textAnchor="middle">AMPS</text>
<text x="172" y="526" fill="#fff" fontSize="15" textAnchor="end">{ampsS >= 0 ? '+' : ''}{ampsS.toFixed(1)}</text>
</svg>
);
}
function Bar({ y, label, val, min, max, value, zones }) {
const x0 = 8, x1 = 182, bw = x1 - x0;
const px = (v) => x0 + bw * Math.max(0, Math.min(1, (v - min) / (max - min)));
const p = px(value);
return (
<g>
<text x={x0} y={y} fill="#39d3c0" fontSize="12">{label}</text>
{val != null && <text x={x1} y={y} fill="#fff" fontSize="16" fontWeight="bold" textAnchor="end">{val}</text>}
<rect x={x0} y={y + 9} width={bw} height="5" fill="#2a2a2a" />
{zones.map((z, i) => <rect key={i} x={px(z.from)} y={y + 9} width={Math.max(0, px(z.to) - px(z.from))} height="5" fill={z.c} />)}
<polygon points={`${p},${y + 9} ${p - 5},${y + 1} ${p + 5},${y + 1}`} fill="#fff" stroke="#000" strokeWidth="0.5" />
</g>
);
}
// Fuel quantity: one bar per the C172's two tanks, with L and R pointers on a
// shared 01020F (gal) scale; yellow/red caution zone at the low end.
function FuelBar({ y, left, right }) {
const x0 = 8, x1 = 182, bw = x1 - x0, max = 26.5;
const px = (g) => x0 + bw * Math.max(0, Math.min(1, g / max));
const tick = (g, lbl) => (
<g key={lbl}>
<line x1={px(g)} y1={y + 16} x2={px(g)} y2={y + 20} stroke="#777" strokeWidth="1" />
<text x={px(g)} y={y + 31} fill="#aaa" fontSize="10" textAnchor="middle">{lbl}</text>
</g>
);
const ptr = (g, lbl) => (
<g>
<polygon points={`${px(g)},${y + 8} ${px(g) - 5},${y} ${px(g) + 5},${y}`} fill="#fff" stroke="#000" strokeWidth="0.5" />
<text x={px(g)} y={y - 2} fill="#fff" fontSize="9" textAnchor="middle">{lbl}</text>
</g>
);
return (
<g>
<text x={x0} y={y - 6} fill="#39d3c0" fontSize="12">FUEL QTY GAL</text>
<rect x={x0} y={y + 8} width={bw} height="6" fill="#2a2a2a" />
<rect x={px(0)} y={y + 8} width={px(2.5) - px(0)} height="6" fill="#c00" />
<rect x={px(2.5)} y={y + 8} width={px(5) - px(2.5)} height="6" fill="#dd0" />
<rect x={px(5)} y={y + 8} width={px(max) - px(5)} height="6" fill="#0c0" />
{tick(0, '0')}{tick(8.83, '10')}{tick(17.66, '20')}{tick(max, 'F')}
{ptr(left, 'L')}{ptr(right, 'R')}
</g>
);
}
function RpmArc({ rpm }) {
const max = 2700, frac = Math.max(0, Math.min(1, rpm / max));
const a0 = -210, a1 = 30, ang = a0 + (a1 - a0) * frac;
const cx = 95, cy = 62, r = 42;
const pt = (deg, rr) => [cx + rr * Math.cos((deg * Math.PI) / 180), cy + rr * Math.sin((deg * Math.PI) / 180)];
const arc = (s, e, color, w) => {
const [x0, y0] = pt(s, r), [x1, y1] = pt(e, r);
return <path d={`M${x0} ${y0} A${r} ${r} 0 ${e - s > 180 ? 1 : 0} 1 ${x1} ${y1}`} fill="none" stroke={color} strokeWidth={w} />;
};
const [nx, ny] = pt(ang, r - 2);
return (
<g fontFamily="monospace">
{arc(a0, a1, '#2a2a2a', 7)}
{arc(a0, -30, '#0c0', 7)}
{arc(0, a1, '#c00', 7)}
<line x1={cx} y1={cy} x2={nx} y2={ny} stroke="#fff" strokeWidth="2.5" />
<circle cx={cx} cy={cy} r="3" fill="#fff" />
<text x={cx} y={cy + 14} fill="#39d3c0" fontSize="12" textAnchor="middle">RPM</text>
<text x={cx} y={cy + 40} fill="#fff" fontSize="26" fontWeight="bold" textAnchor="middle">{Math.round(rpm)}</text>
</g>
);
}
/* ---------------- map chrome overlay (compass rose / range / mode) ---------------- */
const NICE = [0.5, 1, 1.5, 2, 2.5, 4, 5, 7.5, 10, 15, 20, 25, 40, 50, 75, 100, 150, 200, 250, 500];
function niceRange(nm) { let r = NICE[0]; for (const s of NICE) if (nm >= s) r = s; return r; }
function MapChrome({ V, rangeNm }) {
const gs = Math.round(num(V.groundspeed) * 1.94384);
const rng = niceRange(rangeNm);
const wd = ((Math.round(num(V.windDir)) % 360) + 360) % 360, ws = Math.round(num(V.windSpd));
return (
<div className="map-chrome">
{/* the compass rose now lives in MapView, anchored to the aircraft */}
<div className="mc-tr"><b>{gs} KT</b><span>NORTH UP</span></div>
<div className="mc-wind">
{ws >= 1
? (<><span className="mc-windarr" style={{ transform: `rotate(${wd + 180}deg)` }}></span><span>{String(wd).padStart(3, '0')}° {ws}<i>kt</i></span></>)
: <span>CALM</span>}
</div>
<div className="mc-range">{rng} NM</div>
</div>
);
}