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>
This commit is contained in:
2026-06-04 03:05:40 +02:00
parent a32b5a9b06
commit 474a35c6e3
3 changed files with 61 additions and 1 deletions
+3 -1
View File
@@ -78,6 +78,7 @@ export default function Bezel({ variant = 'pfd', xp, svt3d, onToggleSvt, svtOpts
else if (label === 'AIRSPACE') onMapMode && onMapMode((m) => ({ ...m, airspace: !m.airspace })); else if (label === 'AIRSPACE') onMapMode && onMapMode((m) => ({ ...m, airspace: !m.airspace }));
else if (label === 'TRAFFIC') onMapMode && onMapMode((m) => ({ ...m, traffic: !m.traffic })); else if (label === 'TRAFFIC') onMapMode && onMapMode((m) => ({ ...m, traffic: !m.traffic }));
else if (label === 'NEXRAD') onMapMode && onMapMode((m) => ({ ...m, nexrad: !m.nexrad })); else if (label === 'NEXRAD') onMapMode && onMapMode((m) => ({ ...m, nexrad: !m.nexrad }));
else if (label === 'PROFILE') onMapMode && onMapMode((m) => ({ ...m, profile: !m.profile }));
} else { } else {
if (label === 'PFD') setPage('pfd'); if (label === 'PFD') setPage('pfd');
else if (label === 'BACK') setPage({ xpdrcode: 'xpdr', altunit: 'pfd' }[page] || 'root'); else if (label === 'BACK') setPage({ xpdrcode: 'xpdr', altunit: 'pfd' }[page] || 'root');
@@ -120,7 +121,8 @@ export default function Bezel({ variant = 'pfd', xp, svt3d, onToggleSvt, svtOpts
if (variant === 'mfd') return (label === 'TOPO' && mapMode?.base === 'topo') if (variant === 'mfd') return (label === 'TOPO' && mapMode?.base === 'topo')
|| (label === 'TERRAIN' && mapMode?.terrain) || (label === 'OSM' && mapMode?.base === 'osm') || (label === 'TERRAIN' && mapMode?.terrain) || (label === 'OSM' && mapMode?.base === 'osm')
|| (label === 'DCLTR' && mapMode?.dcltr > 0) || (label === 'AIRWAYS' && mapMode?.airways) || (label === 'DCLTR' && mapMode?.dcltr > 0) || (label === 'AIRWAYS' && mapMode?.airways)
|| (label === 'AIRSPACE' && mapMode?.airspace) || (label === 'TRAFFIC' && mapMode?.traffic) || (label === 'NEXRAD' && mapMode?.nexrad); || (label === 'AIRSPACE' && mapMode?.airspace) || (label === 'TRAFFIC' && mapMode?.traffic) || (label === 'NEXRAD' && mapMode?.nexrad)
|| (label === 'PROFILE' && mapMode?.profile);
return (label === 'SYN TERR' && svt3d) || (label === 'PATHWAY' && svtOpts?.pathway) || (label === 'APTSIGNS' && svtOpts?.aptSigns) return (label === 'SYN TERR' && svt3d) || (label === 'PATHWAY' && svtOpts?.pathway) || (label === 'APTSIGNS' && svtOpts?.aptSigns)
|| (label === 'INSET' && inset) || (label === 'NRST' && nrst) || (label === 'TMR/REF' && tmr) || (label === 'INSET' && inset) || (label === 'NRST' && nrst) || (label === 'TMR/REF' && tmr)
|| (label === 'DME' && dme) || (label === 'OBS' && obs) || (label === 'CAUTION' && (alerts || hasAlerts)) || (label === 'DME' && dme) || (label === 'OBS' && obs) || (label === 'CAUTION' && (alerts || hasAlerts))
+54
View File
@@ -54,6 +54,7 @@ export default function MFD({ values: V, flightPlan, fp, mapMode, page = 'map',
terrain={xp?.terrain} rose onView={({ rangeNm }) => setRangeNm(rangeNm)} /> terrain={xp?.terrain} rose onView={({ rangeNm }) => setRangeNm(rangeNm)} />
<MapChrome V={V} rangeNm={rangeNm} /> <MapChrome V={V} rangeNm={rangeNm} />
</div> </div>
{page === 'map' && mapMode?.profile && <VertProfile V={V} fp={flightPlan} />}
{page === 'nrst' && <Nearest xp={xp} full />} {page === 'nrst' && <Nearest xp={xp} full />}
{page === 'fpl' && xp && <FplPage xp={xp} full vnav={vnav} onVnav={onVnav} />} {page === 'fpl' && xp && <FplPage xp={xp} full vnav={vnav} onVnav={onVnav} />}
{/* page-group indicator (bottom-right), like the real G1000 — selected {/* page-group indicator (bottom-right), like the real G1000 — selected
@@ -123,6 +124,59 @@ function MfdTopBar({ V, fp }) {
); );
} }
/* ---------------- 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) ---------------- */ /* ---------------- engine instrument strip (EIS) ---------------- */
function EisStrip({ V }) { function EisStrip({ V }) {
const rpm = arr(V.engRpm); const rpm = arr(V.engRpm);
+4
View File
@@ -572,6 +572,10 @@ body {
.mfd-body { flex: 1; display: flex; min-height: 0; } .mfd-body { flex: 1; display: flex; min-height: 0; }
.eis-svg { width: 178px; flex-shrink: 0; height: 100%; background: #0a0a0a; border-right: 1px solid #222; } .eis-svg { width: 178px; flex-shrink: 0; height: 100%; background: #0a0a0a; border-right: 1px solid #222; }
.mfd-map { flex: 1; position: relative; min-width: 0; } .mfd-map { flex: 1; position: relative; min-width: 0; }
/* vertical profile strip (PROFILE softkey) — overlays the lower map */
.vprof { position: absolute; left: 0; right: 0; bottom: 0; height: 26%; min-height: 120px;
background: #05080b; border-top: 1px solid #2a4250; z-index: 500; }
.vprof svg { width: 100%; height: 100%; display: block; }
.leaflet-host.dark { background: #000; } .leaflet-host.dark { background: #000; }
/* G1000 chrome over the map */ /* G1000 chrome over the map */
.map-chrome { position: absolute; inset: 0; pointer-events: none; z-index: 650; } .map-chrome { position: absolute; inset: 0; pointer-events: none; z-index: 650; }