From 474a35c6e3bd0428666e6d12f4e02f549da50d78 Mon Sep 17 00:00:00 2001 From: karim Date: Thu, 4 Jun 2026 03:05:40 +0200 Subject: [PATCH] MFD PROFILE: vertical situation view (altitude vs distance along the plan) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- web/src/components/Bezel.jsx | 4 ++- web/src/components/MFD.jsx | 54 ++++++++++++++++++++++++++++++++++++ web/src/styles.css | 4 +++ 3 files changed, 61 insertions(+), 1 deletion(-) diff --git a/web/src/components/Bezel.jsx b/web/src/components/Bezel.jsx index a6bea41..0c451a5 100644 --- a/web/src/components/Bezel.jsx +++ b/web/src/components/Bezel.jsx @@ -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 === 'TRAFFIC') onMapMode && onMapMode((m) => ({ ...m, traffic: !m.traffic })); else if (label === 'NEXRAD') onMapMode && onMapMode((m) => ({ ...m, nexrad: !m.nexrad })); + else if (label === 'PROFILE') onMapMode && onMapMode((m) => ({ ...m, profile: !m.profile })); } else { if (label === 'PFD') setPage('pfd'); 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') || (label === 'TERRAIN' && mapMode?.terrain) || (label === 'OSM' && mapMode?.base === 'osm') || (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) || (label === 'INSET' && inset) || (label === 'NRST' && nrst) || (label === 'TMR/REF' && tmr) || (label === 'DME' && dme) || (label === 'OBS' && obs) || (label === 'CAUTION' && (alerts || hasAlerts)) diff --git a/web/src/components/MFD.jsx b/web/src/components/MFD.jsx index a87655a..9876153 100644 --- a/web/src/components/MFD.jsx +++ b/web/src/components/MFD.jsx @@ -54,6 +54,7 @@ export default function MFD({ values: V, flightPlan, fp, mapMode, page = 'map', terrain={xp?.terrain} rose onView={({ rangeNm }) => setRangeNm(rangeNm)} /> + {page === 'map' && mapMode?.profile && } {page === 'nrst' && } {page === 'fpl' && xp && } {/* 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 ( +
+ + + {grid.map((a) => ( + + + {Math.round(a / 100) * 100} + + ))} + {/* planned vertical path through waypoint target altitudes */} + {altPts.length > 0 && } + {/* waypoints */} + {pts.map((p, i) => ( + + + {p.alt != null && } + {p.id} + {p.alt != null && {p.alt}} + + ))} + {/* own aircraft at the left edge */} + + {Math.round(alt)} + +
+ ); +} + /* ---------------- engine instrument strip (EIS) ---------------- */ function EisStrip({ V }) { const rpm = arr(V.engRpm); diff --git a/web/src/styles.css b/web/src/styles.css index fec4d2b..881bed6 100644 --- a/web/src/styles.css +++ b/web/src/styles.css @@ -572,6 +572,10 @@ body { .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; } .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; } /* G1000 chrome over the map */ .map-chrome { position: absolute; inset: 0; pointer-events: none; z-index: 650; }