diff --git a/server/config.js b/server/config.js index a08e566..86ce91f 100644 --- a/server/config.js +++ b/server/config.js @@ -138,6 +138,11 @@ export const WRITABLE_DATAREFS = { xpdrCode: 'sim/cockpit2/radios/actuators/transponder_code', // 4-digit squawk cdiSrc: 'sim/cockpit2/radios/actuators/HSI_source_select_pilot', // 0 NAV1 · 1 NAV2 · 2 GPS (CDI softkey cycles it) fuelTot: 'sim/cockpit2/fuel/fuel_totalizer_sum_kg', // SYSTEM → DEC/INC/RST FUEL adjusts the totalizer + // NRST page: load a selected airport's tower/CTAF into COM standby, or a VOR into NAV standby. + com1Sb: 'sim/cockpit2/radios/actuators/com1_standby_frequency_hz', + com2Sb: 'sim/cockpit2/radios/actuators/com2_standby_frequency_hz', + nav1Sb: 'sim/cockpit2/radios/actuators/nav1_standby_frequency_hz', + nav2Sb: 'sim/cockpit2/radios/actuators/nav2_standby_frequency_hz', }; // Commands the frontend may TRIGGER (autopilot mode buttons etc.). diff --git a/web/src/App.jsx b/web/src/App.jsx index d37ceab..411f82a 100644 --- a/web/src/App.jsx +++ b/web/src/App.jsx @@ -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 && setWin(null)} />} + {dto && setWin(null)} vnav={vnavCfg} onVnav={setVnavCfg} />} {proc && setWin(null)} />} {fpl && (
setWin(null)}> -
e.stopPropagation()}> setWin(null)} />
+
e.stopPropagation()}> setWin(null)} vnav={vnavCfg} onVnav={setVnavCfg} />
)} @@ -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)}> - setWin(null)} + 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} )} {tab === 'mfd' && ( toggleWin('dto')} onProc={() => toggleWin('proc')} onFms={cycleMfd} onFpl={() => setMfdPage('fpl')} onClr={() => setWin(null)}> - + {dialogs} )} diff --git a/web/src/components/DirectTo.jsx b/web/src/components/DirectTo.jsx index fca5f74..5f94145 100644 --- a/web/src/components/DirectTo.jsx +++ b/web/src/components/DirectTo.jsx @@ -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 }) { )}
- ALT_____FTOFFSET+0NM + ALT + + setAltFt(e.target.value.replace(/[^0-9]/g, '').slice(0, 5))} + placeholder="_____" />FT + + + OFFSET + + + {cfg.offsetNm || 0}NM + + BRG{preview ? `${String(Math.round(preview.brg)).padStart(3, '0')}°` : '___°'} + FPA{(cfg.fpa || 3).toFixed(1)}° DIS{preview ? `${preview.dist.toFixed(1)}NM` : '__._NM'} CRS{preview ? `${String(Math.round(preview.brg)).padStart(3, '0')}°` : '___°'} -
diff --git a/web/src/components/FplPage.jsx b/web/src/components/FplPage.jsx index bbcd8b4..5499a64 100644 --- a/web/src/components/FplPage.jsx +++ b/web/src/components/FplPage.jsx @@ -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 }) { : <>TIME TO BOD{fmtSec(vnav.bodSec)}} V DEV{vnav.vDev >= 0 ? '+' : ''}{Math.round(vnav.vDev)}FT
- ) :
— no active VNAV profile —
} + ) :
{cfg.enabled ? '— no active VNAV profile —' : '— VNAV cancelled —'}
} + {onVnav && ( +
+ + + {cfg.fpa.toFixed(1)}° + + + {cfg.offsetNm || 0}NM + + +
+ )} )}
diff --git a/web/src/components/MFD.jsx b/web/src/components/MFD.jsx index bdcede4..a87655a 100644 --- a/web/src/components/MFD.jsx +++ b/web/src/components/MFD.jsx @@ -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)} />
- {page === 'nrst' && } - {page === 'fpl' && xp && } + {page === 'nrst' && } + {page === 'fpl' && xp && } {/* page-group indicator (bottom-right), like the real G1000 — selected by the FMS knob; tappable as a touch fallback. */} } + + + )} )) : rows.map((f, i) => ( @@ -76,10 +100,16 @@ export default function Nearest({ values, onClose, full = false }) { {String(num(f.brg)).padStart(3, '0')}° {num(f.dist).toFixed(1)}nm {freqStr(f.freq, type)} - {f.name && {f.name}} + {xp && ( + + {type === 'vor' && f.freq && } + + + )} ))} + {msg &&
{msg}
} ); } diff --git a/web/src/components/PFD.jsx b/web/src/components/PFD.jsx index fb4ac66..904e013 100644 --- a/web/src/components/PFD.jsx +++ b/web/src/components/PFD.jsx @@ -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, )} - {nrst && } + {nrst && } {tmr && } {dme && } {alerts && } diff --git a/web/src/styles.css b/web/src/styles.css index a95f6a5..00a3169 100644 --- a/web/src/styles.css +++ b/web/src/styles.css @@ -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 */