Manual audit B/C/D: VNAV control, Direct-To descent, NRST actions

D — NRST page: each nearest entry can now load its tower/CTAF into COM1 standby
(→COM) or a VOR into NAV1 standby (→NAV), and fly Direct-To it (D→). Nearest now
takes xp; com/nav standby datarefs made writable.

C — Direct-To with VNAV descent: the DTO dialog's ALT (MSL/AGL) and OFFSET fields
are now editable; entering an altitude makes the target a designated VNAV fix
(alt+dsgn) and arms VNAV, so the descent profile + PFD chevrons compute.

B — VNAV control: shared vnav config (enabled/fpa/offsetNm) threaded to PFD +
FplPage. The CURRENT VNV PROFILE panel gains ENBL/CNCL VNV, FPA ±, along-track
ATK ± and VNV-D→ keys; the profile + PFD chevrons honour the chosen FPA/offset
and hide when cancelled.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
2026-06-04 01:33:12 +02:00
parent 5db22c85bc
commit 5f1339f8b3
8 changed files with 136 additions and 36 deletions
+9 -5
View File
@@ -70,6 +70,10 @@ export default function App() {
const fpl = win === 'fpl', dto = win === 'dto', proc = win === 'proc';
// MFD map mode (base layer + overlays), switched via the Map-Opt softkeys.
const [mapMode, setMapMode] = useState({ base: 'topo' });
// VNAV profile control (FPL VNAV keys + Direct-To descent): enabled gates the
// profile/chevrons, fpa is the descent angle (°), offsetNm levels off that far
// before the waypoint. See FplPage CURRENT VNV PROFILE + PFD chevrons.
const [vnavCfg, setVnavCfg] = useState({ enabled: true, fpa: 3, offsetNm: 0 });
// Altimeter barometric units (false = inHg, true = hectopascal) — PFD ALT UNIT softkey.
const [baroHpa, setBaroHpa] = useState(false);
// Barometric minimums (set in TMR/REF) — shown on the PFD altimeter as BARO MIN.
@@ -93,11 +97,11 @@ export default function App() {
// the display's lower-right (like the real unit), not over the whole app.
const dialogs = (
<>
{dto && <DirectTo xp={xp} onClose={() => setWin(null)} />}
{dto && <DirectTo xp={xp} onClose={() => setWin(null)} vnav={vnavCfg} onVnav={setVnavCfg} />}
{proc && <Proc xp={xp} onClose={() => setWin(null)} />}
{fpl && (
<div className="gwin-backdrop" onClick={() => setWin(null)}>
<div onClick={(e) => e.stopPropagation()}><FplPage xp={xp} onClose={() => setWin(null)} /></div>
<div onClick={(e) => e.stopPropagation()}><FplPage xp={xp} onClose={() => setWin(null)} vnav={vnavCfg} onVnav={setVnavCfg} /></div>
</div>
)}
</>
@@ -140,16 +144,16 @@ export default function App() {
tmr={tmr} onToggleTmr={() => toggleWin('tmr')} dme={dme} onToggleDme={() => toggleWin('dme')}
alerts={alerts} onToggleAlerts={() => toggleWin('alerts')} onProc={() => toggleWin('proc')} onFpl={() => toggleWin('fpl')} onClr={() => setWin(null)}
altHpa={baroHpa} onAltUnit={setBaroHpa} obs={obs} onObs={() => setObs((v) => !v)}>
<PFD values={xp.values} command={xp.command} connected={xp.xpConnected} svt={svt3d} inset={inset} insetMode={insetMode} nrst={nrst} onCloseNrst={() => setWin(null)}
<PFD xp={xp} values={xp.values} command={xp.command} connected={xp.xpConnected} svt={svt3d} inset={inset} insetMode={insetMode} nrst={nrst} onCloseNrst={() => setWin(null)}
tmr={tmr} onCloseTmr={() => setWin(null)} dme={dme} onCloseDme={() => setWin(null)}
alerts={alerts} onCloseAlerts={() => setWin(null)} baroHpa={baroHpa} obs={obs}
minimums={minimums} onMinimums={setMinimums} flightPlan={xp.flightPlan} fp={xp.fp} />
minimums={minimums} onMinimums={setMinimums} flightPlan={xp.flightPlan} fp={xp.fp} vnav={vnavCfg} />
{dialogs}
</Bezel>
)}
{tab === 'mfd' && (
<Bezel variant="mfd" xp={xp} knobMode={knobMode} mapMode={mapMode} onMapMode={setMapMode} onDirect={() => toggleWin('dto')} onProc={() => toggleWin('proc')} onFms={cycleMfd} onFpl={() => setMfdPage('fpl')} onClr={() => setWin(null)}>
<MFD values={xp.values} flightPlan={xp.flightPlan} fp={xp.fp} mapMode={mapMode} page={mfdPage} onCycle={cycleMfd} xp={xp} />
<MFD values={xp.values} flightPlan={xp.flightPlan} fp={xp.fp} mapMode={mapMode} page={mfdPage} onCycle={cycleMfd} xp={xp} vnav={vnavCfg} onVnav={setVnavCfg} />
{dialogs}
</Bezel>
)}
+25 -4
View File
@@ -17,11 +17,14 @@ function distBrg(la1, lo1, la2, lo2) {
return { dist, brg };
}
export default function DirectTo({ xp, onClose }) {
export default function DirectTo({ xp, onClose, vnav, onVnav }) {
const { values, fp, command } = xp;
const cfg = vnav || { enabled: true, fpa: 3, offsetNm: 0 };
const [entry, setEntry] = useState('');
const [hits, setHits] = useState([]);
const [sel, setSel] = useState(null); // chosen { id, lat, lon, type }
const [altFt, setAltFt] = useState(''); // optional VNAV target altitude
const [agl, setAgl] = useState(false); // MSL vs AGL reference (for airports)
const inputRef = useRef(null);
useEffect(() => { inputRef.current?.focus(); }, []);
@@ -40,11 +43,17 @@ export default function DirectTo({ xp, onClose }) {
const activate = () => {
if (!sel) return;
// Optional VNAV descent: a target altitude makes the Direct-To waypoint a
// designated VNAV fix, so the CURRENT VNV PROFILE + PFD chevrons compute the
// descent (FPA/offset from the shared VNAV config). AGL adds field elevation.
const a = parseInt(altFt, 10);
const tgtAlt = isFinite(a) && a > 0 ? (agl ? a + (num(sel.elev) || 0) : a) : null;
fp.set({ name: 'ACTIVE', waypoints: [
{ id: 'PPOS', lat, lon, type: 'USR' },
{ id: sel.id, lat: sel.lat, lon: sel.lon, type: sel.type || 'WPT' },
{ id: sel.id, lat: sel.lat, lon: sel.lon, type: sel.type || 'WPT', ...(tgtAlt ? { alt: tgtAlt, dsgn: true } : {}) },
] });
command('direct'); // mirror to the in-sim G1000
if (tgtAlt && onVnav) onVnav((c) => ({ ...c, enabled: true })); // arm VNAV for the descent
onClose();
};
@@ -74,11 +83,23 @@ export default function DirectTo({ xp, onClose }) {
</div>
)}
<div className="dto-grid">
<b>ALT</b><span>_____FT</span><b>OFFSET</b><span>+0NM</span>
<b>ALT</b>
<span className="dto-altedit">
<input className="dto-alt" value={altFt} inputMode="numeric"
onChange={(e) => setAltFt(e.target.value.replace(/[^0-9]/g, '').slice(0, 5))}
placeholder="_____" />FT
<button className="dto-unit" onClick={() => setAgl((g) => !g)}>{agl ? 'AGL' : 'MSL'}</button>
</span>
<b>OFFSET</b>
<span className="dto-off">
<button onClick={() => onVnav && onVnav((c) => ({ ...c, offsetNm: Math.max(0, (c.offsetNm || 0) - 1) }))}></button>
{cfg.offsetNm || 0}NM
<button onClick={() => onVnav && onVnav((c) => ({ ...c, offsetNm: Math.min(20, (c.offsetNm || 0) + 1) }))}>+</button>
</span>
<b>BRG</b><span>{preview ? `${String(Math.round(preview.brg)).padStart(3, '0')}°` : '___°'}</span>
<b>FPA</b><span>{(cfg.fpa || 3).toFixed(1)}°</span>
<b>DIS</b><span>{preview ? `${preview.dist.toFixed(1)}NM` : '__._NM'}</span>
<b>CRS</b><span>{preview ? `${String(Math.round(preview.brg)).padStart(3, '0')}°` : '___°'}</span>
<span /><span />
</div>
<div className="dto-foot">
<button className="dto-act" disabled={!sel} onClick={activate}>ACTIVATE</button>
+25 -10
View File
@@ -18,7 +18,8 @@ function brng(a, b) {
}
const fmtHrs = (h) => { const m = Math.round(h * 60); return `${Math.floor(m / 60)}:${String(m % 60).padStart(2, '0')}`; };
export default function FplPage({ xp, full = false, onClose }) {
export default function FplPage({ xp, full = false, onClose, vnav: vnavCfg, onVnav }) {
const cfg = vnavCfg || { enabled: true, fpa: 3, offsetNm: 0 };
const { flightPlan, fp, values, exportMsg } = xp;
const wps = flightPlan.waypoints || [];
const active = Math.max(1, Math.min(wps.length - 1, flightPlan.activeLeg ?? 1));
@@ -69,22 +70,24 @@ export default function FplPage({ xp, full = false, onClose }) {
// from the path, time-to-top-of-descent.
const alt = num(values.altitude);
let vnav = null;
if (gs > 40) {
if (cfg.enabled && gs > 40) {
const tan = Math.tan((cfg.fpa * Math.PI) / 180);
const off = Math.max(0, cfg.offsetNm || 0);
let c = 0, pl = num(values.lat), po = num(values.lon);
for (let i = Math.max(1, active); i < wps.length; i++) {
c += distNm({ lat: pl, lon: po }, wps[i]); pl = wps[i].lat; po = wps[i].lon;
const t = num(wps[i].alt);
if (t > 0 && t < alt - 50 && (wps[i].dsgn ?? true)) {
const tan = Math.tan((3 * Math.PI) / 180);
const d = Math.max(0, c - off); // distance to the level-off point (offset before wpt)
const vsTgt = -gs * tan * 101.27;
const vsReq = c > 0 ? (t - alt) / (c / gs * 60) : 0;
const vDev = alt - (t + c * 6076.12 * tan);
const todNm = c - (alt - t) / (6076.12 * tan);
const vsReq = d > 0 ? (t - alt) / (d / gs * 60) : 0;
const vDev = alt - (t + d * 6076.12 * tan);
const todNm = d - (alt - t) / (6076.12 * tan);
// Before TOD: time until the descent path is intercepted. After TOD (already
// descending): time to Bottom of Descent = reaching the target waypoint.
// descending): time to Bottom of Descent = reaching the level-off point.
const todSec = todNm > 0 ? (todNm / gs) * 3600 : 0;
const bodSec = todNm > 0 ? 0 : (c / gs) * 3600;
vnav = { wptId: wps[i].id, tgtAlt: t, vsTgt, vsReq, vDev, fpa: 3.0, todSec, bodSec };
const bodSec = todNm > 0 ? 0 : (d / gs) * 3600;
vnav = { wptId: wps[i].id, tgtAlt: t, vsTgt, vsReq, vDev, fpa: cfg.fpa, todSec, bodSec, off };
break;
}
}
@@ -134,7 +137,19 @@ export default function FplPage({ xp, full = false, onClose }) {
: <><b>TIME TO BOD</b><span>{fmtSec(vnav.bodSec)}</span></>}
<b>V DEV</b><span>{vnav.vDev >= 0 ? '+' : ''}{Math.round(vnav.vDev)}<u>FT</u></span>
</div>
) : <div className="fpl-vnav-none"> no active VNAV profile </div>}
) : <div className="fpl-vnav-none">{cfg.enabled ? '— no active VNAV profile —' : '— VNAV cancelled —'}</div>}
{onVnav && (
<div className="fpl-vnav-keys">
<button className={cfg.enabled ? 'on' : ''} onClick={() => onVnav((c) => ({ ...c, enabled: !c.enabled }))}>{cfg.enabled ? 'CNCL VNV' : 'ENBL VNV'}</button>
<button onClick={() => onVnav((c) => ({ ...c, fpa: +Math.max(2, c.fpa - 0.5).toFixed(1) }))}>FPA</button>
<span className="vk-val">{cfg.fpa.toFixed(1)}°</span>
<button onClick={() => onVnav((c) => ({ ...c, fpa: +Math.min(6, c.fpa + 0.5).toFixed(1) }))}>FPA+</button>
<button onClick={() => onVnav((c) => ({ ...c, offsetNm: Math.max(0, (c.offsetNm || 0) - 1) }))}>ATK</button>
<span className="vk-val">{cfg.offsetNm || 0}<u>NM</u></span>
<button onClick={() => onVnav((c) => ({ ...c, offsetNm: Math.min(20, (c.offsetNm || 0) + 1) }))}>ATK+</button>
<button className="vnvd" onClick={() => onVnav((c) => ({ ...c, enabled: true }))}>VNV-D</button>
</div>
)}
</div>
)}
<div className="fpl-entry">
+3 -3
View File
@@ -38,7 +38,7 @@ const fmtEte = (s) => {
// 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 }) {
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 (
@@ -54,8 +54,8 @@ export default function MFD({ values: V, flightPlan, fp, mapMode, page = 'map',
terrain={xp?.terrain} rose onView={({ rangeNm }) => setRangeNm(rangeNm)} />
<MapChrome V={V} rangeNm={rangeNm} />
</div>
{page === 'nrst' && <Nearest values={V} full />}
{page === 'fpl' && xp && <FplPage xp={xp} full />}
{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)">
+35 -5
View File
@@ -5,7 +5,8 @@ import { num } from '../api/useXplane.js';
// press the NRST softkey; it lists the closest airports / VORs / NDBs to the
// aircraft with bearing + distance, straight from X-Plane's own nav data
// (/api/nav/nearest). Tabs switch the feature type, like turning the FMS knob
// through the NRST page group on the real unit.
// through the NRST page group on the real unit. Each entry can be acted on:
// load its frequency into the COM/NAV standby, or fly Direct-To it (manual p.23).
const TABS = [
{ id: 'apt', label: 'APT' },
{ id: 'vor', label: 'VOR' },
@@ -19,10 +20,11 @@ const freqStr = (f, type) => {
return type === 'vor' ? (n / 100).toFixed(2) : String(n);
};
export default function Nearest({ values, onClose, full = false }) {
export default function Nearest({ xp, values: valuesProp, onClose, full = false }) {
const values = xp?.values || valuesProp || {};
const [type, setType] = useState('apt');
const [rows, setRows] = useState([]);
const lastRef = useRef(null);
const [msg, setMsg] = useState('');
const lat = num(values.lat), lon = num(values.lon);
useEffect(() => {
@@ -36,11 +38,27 @@ export default function Nearest({ values, onClose, full = false }) {
} catch { /* aborted / offline */ }
};
load();
// Refresh as the aircraft moves (cheap server scan).
timer = setInterval(load, 5000);
return () => { abort.abort(); clearInterval(timer); };
}, [type, Math.round(lat * 50), Math.round(lon * 50)]); // re-key on ~1nm moves
const flash = (t) => { setMsg(t); setTimeout(() => setMsg(''), 1800); };
// Fly Direct-To: a magenta leg from present position to the chosen feature.
const directTo = (f) => {
if (!xp || !isFinite(f.lat)) return;
xp.fp.set({ name: 'ACTIVE', waypoints: [
{ id: 'PPOS', lat, lon, type: 'USR' },
{ id: f.id, lat: f.lat, lon: f.lon, type: f.type || (type === 'apt' ? 'APT' : type === 'vor' ? 'VOR' : 'NDB') },
] });
xp.command('direct');
flash(`Direct-To ${f.id}`);
onClose && onClose();
};
// Load a frequency into COM1 / NAV1 standby (freq units are 10 kHz, e.g. 11990).
const toCom = (f) => { if (xp && f.com) { xp.setDataref('com1Sb', Math.round(f.com.freq * 100)); flash(`COM1 STBY ${f.com.freq.toFixed(3)}`); } };
const toNav = (f) => { if (xp && f.freq) { xp.setDataref('nav1Sb', Math.round(num(f.freq))); flash(`NAV1 STBY ${(num(f.freq) / 100).toFixed(2)}`); } };
return (
<div className={`nrst-window ${full ? 'full' : ''}`}>
<div className="nrst-head">
@@ -68,6 +86,12 @@ export default function Nearest({ values, onClose, full = false }) {
<span className="apt-rwlbl">RNWY</span>
<span className="apt-rw">{f.rwyFt ? `${f.rwyFt}FT` : '—'}</span>
</div>
{xp && (
<div className="nrst-acts">
{f.com && <button className="nrst-act" onClick={() => toCom(f)} title="→ COM1 standby">COM</button>}
<button className="nrst-act dto" onClick={() => directTo(f)} title="Direct-To">D</button>
</div>
)}
</div>
))
: rows.map((f, i) => (
@@ -76,10 +100,16 @@ export default function Nearest({ values, onClose, full = false }) {
<span className="c-brg">{String(num(f.brg)).padStart(3, '0')}°</span>
<span className="c-dis">{num(f.dist).toFixed(1)}<u>nm</u></span>
<span className="c-xtra">{freqStr(f.freq, type)}</span>
{f.name && <span className="c-name">{f.name}</span>}
{xp && (
<span className="nrst-acts">
{type === 'vor' && f.freq && <button className="nrst-act" onClick={() => toNav(f)} title="→ NAV1 standby">NAV</button>}
<button className="nrst-act dto" onClick={() => directTo(f)} title="Direct-To">D</button>
</span>
)}
</div>
))}
</div>
{msg && <div className="nrst-msg">{msg}</div>}
</div>
);
}
+13 -9
View File
@@ -63,7 +63,10 @@ function fmtEte(s) {
// target VS for a -3° flight path, required VS to make the restriction, vertical
// deviation from that path, and time-to-top-of-descent. (Manual S.64 / S.107.)
const VNAV_FPA = 3.0; // default flight-path angle (degrees)
function vnavInfo(V, fp) {
function vnavInfo(V, fp, cfg = { enabled: true, fpa: VNAV_FPA, offsetNm: 0 }) {
if (!cfg.enabled) return null; // VNAV cancelled (CNCL VNV)
const fpa = cfg.fpa || VNAV_FPA;
const off = Math.max(0, cfg.offsetNm || 0);
const wps = fp?.waypoints || [];
const ai = Math.max(1, Math.min(wps.length - 1, fp?.activeLeg ?? 1));
const alt = num(V.altitude);
@@ -76,16 +79,17 @@ function vnavInfo(V, fp) {
prevLat = wps[i].lat; prevLon = wps[i].lon;
const tgt = num(wps[i].alt);
if (tgt > 0 && tgt < alt - 50 && (wps[i].dsgn ?? true)) {
const tan = Math.tan((VNAV_FPA * Math.PI) / 180);
const tMin = (cum / gs) * 60;
const tan = Math.tan((fpa * Math.PI) / 180);
const d = Math.max(0, cum - off); // distance to level-off point
const tMin = (d / gs) * 60;
const vsReq = tMin > 0 ? (tgt - alt) / tMin : 0; // fpm to make the fix
const vsTgt = -gs * tan * 101.27; // fpm for the FPA at this GS
const desiredAltNow = tgt + cum * 6076.12 * tan; // path altitude at present position
const desiredAltNow = tgt + d * 6076.12 * tan; // path altitude at present position
const vDev = alt - desiredAltNow; // + = above path
const descentNm = (alt - tgt) / (6076.12 * tan); // distance the descent itself takes
const todNm = cum - descentNm; // distance ahead until TOD
const todNm = d - descentNm; // distance ahead until TOD
const todSec = todNm > 0 ? (todNm / gs) * 3600 : 0;
return { wptId: wps[i].id, tgtAlt: tgt, dist: cum, vsReq, vsTgt, vDev, fpa: VNAV_FPA, todSec };
return { wptId: wps[i].id, tgtAlt: tgt, dist: cum, vsReq, vsTgt, vDev, fpa, todSec };
}
}
return null;
@@ -100,7 +104,7 @@ const SVT_BOX = { x: 0, y: 74, w: W, h: H - 74 };
// The INSET moving map sits in the bottom-left corner (toggled by INSET softkey).
const INSET_BOX = { x: 6, y: 556, w: 300, h: 172 };
export default function PFD({ values: V, command, connected = true, svt = true, inset = false, insetMode, nrst = false, onCloseNrst, tmr = false, onCloseTmr, dme = false, onCloseDme, alerts = false, onCloseAlerts, baroHpa = false, obs = false, minimums, onMinimums, flightPlan, fp }) {
export default function PFD({ xp, values: V, command, connected = true, svt = true, inset = false, insetMode, nrst = false, onCloseNrst, tmr = false, onCloseTmr, dme = false, onCloseDme, alerts = false, onCloseAlerts, baroHpa = false, obs = false, minimums, onMinimums, flightPlan, fp, vnav: vnavCfg }) {
const wrapRef = useRef(null);
const svgRef = useRef(null);
const [box, setBox] = useState(null);
@@ -137,7 +141,7 @@ export default function PFD({ values: V, command, connected = true, svt = true,
}, []);
const nav = activeNav(V, flightPlan);
const vnav = vnavInfo(V, flightPlan);
const vnav = vnavInfo(V, flightPlan, vnavCfg);
// GPS phase annunciation: APR when an approach leg is active, TERM within 30 nm
// of the destination, otherwise ENR (manual).
const gpsPhase = (() => {
@@ -203,7 +207,7 @@ export default function PFD({ values: V, command, connected = true, svt = true,
</g>
)}
</svg>
{nrst && <Nearest values={V} onClose={onCloseNrst} />}
{nrst && <Nearest xp={xp} onClose={onCloseNrst} />}
{tmr && <TimerRef values={V} onClose={onCloseTmr} minimums={minimums} onMinimums={onMinimums} />}
{dme && <DmeWindow V={V} onClose={onCloseDme} />}
{alerts && <AlertsWindow V={V} onClose={onCloseAlerts} />}
+21
View File
@@ -182,6 +182,13 @@ body {
.apt-app.ils { color: #16d24a; }
.apt-comlbl { color: #6f808d; font-size: 11px; }
.apt-com { color: #fff; font-size: 13px; }
/* NRST per-entry actions: load freq to standby, or fly Direct-To */
.nrst-acts { display: flex; gap: 6px; margin-top: 2px; justify-content: flex-end; }
.nrst-row .nrst-acts { display: inline-flex; margin-top: 0; margin-left: 6px; }
.nrst-act { background: #11202a; border: 1px solid #2a4250; color: #7fd4ff; font: inherit; font-size: 10px; padding: 1px 6px; border-radius: 2px; cursor: pointer; letter-spacing: .5px; }
.nrst-act:hover { background: #163243; }
.nrst-act.dto { color: #e89bff; border-color: #5a3a66; }
.nrst-msg { color: #16d24a; font-size: 11px; padding: 4px 8px; text-align: center; }
.apt-rwlbl { color: #6f808d; font-size: 11px; }
.apt-rw { color: #fff; font-size: 13px; text-align: right; }
.nrst-head { display: flex; align-items: center; gap: 8px; padding: 5px 8px; background: #11161b; border-bottom: 1px solid #2c343c; }
@@ -260,6 +267,20 @@ body {
.fpl-vnav-grid span u { color: #6f808d; font-size: 9px; text-decoration: none; margin-left: 1px; }
.fpl-vnav-grid .vwpt { color: #4fa8ff; }
.fpl-vnav-none { color: #6f808d; font-size: 12px; }
/* VNAV control keys (ENBL/CNCL VNV, FPA, along-track offset, VNV Direct-To) */
.fpl-vnav-keys { display: flex; flex-wrap: wrap; align-items: center; gap: 5px; margin-top: 7px; }
.fpl-vnav-keys button { background: #11202a; border: 1px solid #2a4250; color: #7fd4ff; font: inherit; font-size: 11px; padding: 2px 7px; border-radius: 2px; cursor: pointer; letter-spacing: .5px; }
.fpl-vnav-keys button:hover { background: #163243; }
.fpl-vnav-keys button.on { background: #16d24a22; border-color: #16d24a; color: #16d24a; }
.fpl-vnav-keys button.vnvd { color: #e89bff; border-color: #5a3a66; }
.fpl-vnav-keys .vk-val { color: #fff; font-size: 13px; min-width: 36px; text-align: center; }
.fpl-vnav-keys .vk-val u { color: #6f808d; font-size: 9px; text-decoration: none; }
/* Direct-To VNAV editable fields */
.dto-altedit { display: inline-flex; align-items: center; gap: 4px; color: #6f808d; font-size: 10px; }
.dto-alt { width: 56px; background: #0a1016; border: 1px solid #2a4250; color: #36d2ff; font: inherit; font-size: 14px; text-align: right; padding: 1px 4px; border-radius: 2px; }
.dto-unit { background: #11202a; border: 1px solid #2a4250; color: #7fd4ff; font: inherit; font-size: 10px; padding: 1px 5px; border-radius: 2px; cursor: pointer; }
.dto-off { display: inline-flex; align-items: center; gap: 6px; color: #fff; font-size: 13px; }
.dto-off button { background: #11202a; border: 1px solid #2a4250; color: #7fd4ff; font: inherit; width: 20px; height: 20px; border-radius: 2px; cursor: pointer; line-height: 1; }
/* ORIG / DEST subtitle (PFD window) */
.fpl-od { color: #36d2ff; text-align: center; font-family: 'Roboto Mono', monospace; font-size: 14px; padding: 3px 0; border-bottom: 1px solid #1c242c; letter-spacing: 1px; }
/* compact window: DTK/DIS only (drop CUM/ALT), no editor — like the real FPL window */