From ad592a7a77c3a9968af2e1ecd2b9c8ac2c86de35 Mon Sep 17 00:00:00 2001 From: karim Date: Thu, 4 Jun 2026 20:40:12 +0200 Subject: [PATCH] FMS/CDU to manual completeness: FIX page, discontinuities, MOD/EXEC, 2 spd limits MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - FIX INFO page (manual p17-20): reference navaid + crossing radial + distance → computes a fix waypoint (great-circle dest point) and inserts it into the plan. - Flight-plan discontinuities (p25-26): coordinate-less / VECTORS legs render as "─ DISCONTINUITY ─" in LEGS; insert a waypoint to stitch, or LSK to clear it. Distance/track calc now skips disco legs (no NaN). - MOD/ACT + EXEC light: edits arm the EXEC key (glows) and flip the page title to MOD…; EXEC commits/exports and clears it, like the real CDU. - VNAV CLB/DES now take two speed/alt restrictions each (DEL clears the 2nd), per the manual. - Page keys: added FIX; row relaid to 4×2 (FPLN/LEGS/DEP-ARR/DIR-INTC · FIX/VNAV/PROG/MENU). Co-Authored-By: Claude Opus 4.8 --- web/src/components/CDU.jsx | 113 +++++++++++++++++++++++++++++-------- web/src/styles.css | 7 ++- 2 files changed, 95 insertions(+), 25 deletions(-) diff --git a/web/src/components/CDU.jsx b/web/src/components/CDU.jsx index b2c12f6..ea3a262 100644 --- a/web/src/components/CDU.jsx +++ b/web/src/components/CDU.jsx @@ -22,10 +22,21 @@ function brng(a, b) { const x = Math.cos(rad(a.lat)) * Math.sin(rad(b.lat)) - Math.sin(rad(a.lat)) * Math.cos(rad(b.lat)) * Math.cos(rad(b.lon - a.lon)); return (deg(Math.atan2(y, x)) + 360) % 360; } +// point at distance dNm along bearing brgDeg from a start lat/lon (great-circle). +function destPoint(lat, lon, brgDeg, dNm) { + const d = dNm / R_NM, t = rad(brgDeg), p1 = rad(lat), l1 = rad(lon); + const p2 = Math.asin(Math.sin(p1) * Math.cos(d) + Math.cos(p1) * Math.sin(d) * Math.cos(t)); + const l2 = l1 + Math.atan2(Math.sin(t) * Math.sin(d) * Math.cos(p1), Math.cos(d) - Math.sin(p1) * Math.sin(p2)); + return { lat: deg(p2), lon: ((deg(l2) + 540) % 360) - 180 }; +} -const PAGE_KEYS = [['fpln', 'FPLN'], ['legs', 'LEGS'], ['deparr', 'DEP/ARR'], ['dir', 'DIR-INTC'], ['vnav', 'VNAV'], ['prog', 'PROG'], ['menu', 'MENU']]; +const PAGE_KEYS = [['fpln', 'FPLN'], ['legs', 'LEGS'], ['deparr', 'DEP/ARR'], ['dir', 'DIR-INTC'], ['fix', 'FIX'], ['vnav', 'VNAV'], ['prog', 'PROG'], ['menu', 'MENU']]; const LEG_ROWS = 5; const VNAV_PAGES = ['CLB', 'CRZ', 'DES']; +// a flight-plan leg with no coordinates is a discontinuity (e.g. a VECTORS or +// heading-to-altitude procedure leg) — shown as "DISCONTINUITY" and stitched +// over by inserting a waypoint, per the FMS manual (p25-26). +const isDisco = (w) => !w || w.type === 'DISCO' || !isFinite(w.lat) || !isFinite(w.lon); export default function CDU({ xp, vnav: vnavCfg, onVnav }) { const { flightPlan, fp, exportMsg, command } = xp; @@ -49,12 +60,21 @@ export default function CDU({ xp, vnav: vnavCfg, onVnav }) { const [crzAlt, setCrzAlt] = useState(''); const [tgtSpd, setTgtSpd] = useState(''); const [transAlt, setTransAlt] = useState('18000'); // CLB transition altitude (manual default) - const [clbLim, setClbLim] = useState('250/10000'); // climb speed/alt restriction - const [desLim, setDesLim] = useState('250/10000'); // descent speed/alt restriction + const [clbLim, setClbLim] = useState(['250/10000', '']); // up to 2 climb speed/alt restrictions + const [desLim, setDesLim] = useState(['250/10000', '']); // up to 2 descent speed/alt restrictions const [vnavPage, setVnavPage] = useState(0); // 0 CLB · 1 CRZ · 2 DES + // FIX INFO (manual p17-20): reference navaid + radial/distance → a fix waypoint + const [fixRef, setFixRef] = useState(null); // resolved reference navaid {id,lat,lon} + const [fixRad, setFixRad] = useState(''); // crossing radial (deg) + const [fixDist, setFixDist] = useState(''); // distance along radial (NM) + // MOD/ACT: the EXEC light arms while edits are pending, like the real CDU. + const [mod, setMod] = useState(false); // saved-plan list (MENU) const [plans, setPlans] = useState(null); + // every plan edit goes through here so the EXEC light arms (MOD state). + const editPlan = (plan) => { fp.set(plan); setMod(true); }; + const flash = (t) => { setMsg(t); setTimeout(() => setMsg(''), 2000); }; const type = (ch) => { setMsg(''); setScr((s) => (s + ch).slice(0, 12)); }; const clr = () => { if (scr) setScr((s) => s.slice(0, -1)); else { setDel(false); setMsg(''); } }; @@ -71,26 +91,26 @@ export default function CDU({ xp, vnav: vnavCfg, onVnav }) { const setOrigin = async (ident) => { const h = await resolve(ident); if (!h) return flash('NOT IN DATABASE'); - fp.set({ name: 'ACTIVE', waypoints: [{ id: h.id, lat: h.lat, lon: h.lon, type: h.type || 'APT', alt: null }], activeLeg: 1 }); + editPlan({ name: 'ACTIVE', waypoints: [{ id: h.id, lat: h.lat, lon: h.lon, type: h.type || 'APT', alt: null }], activeLeg: 1 }); setScr(''); }; const setDest = async (ident) => { const h = await resolve(ident); if (!h) return flash('NOT IN DATABASE'); const next = wps.filter((w) => w.id !== dest || w.type !== 'APT'); next.push({ id: h.id, lat: h.lat, lon: h.lon, type: h.type || 'APT', alt: null }); - fp.set({ name: 'ACTIVE', waypoints: next, activeLeg: flightPlan.activeLeg ?? 1 }); + editPlan({ name: 'ACTIVE', waypoints: next, activeLeg: flightPlan.activeLeg ?? 1 }); setScr(''); }; const insertAt = async (ident, index) => { const h = await resolve(ident); if (!h) return flash('NOT IN DATABASE'); const next = wps.slice(); next.splice(index, 0, { id: h.id, lat: h.lat, lon: h.lon, type: h.type || 'WPT', alt: null }); - fp.set({ name: 'ACTIVE', waypoints: next, activeLeg: flightPlan.activeLeg ?? 1 }); + editPlan({ name: 'ACTIVE', waypoints: next, activeLeg: flightPlan.activeLeg ?? 1 }); setScr(''); }; const directTo = async (ident) => { const h = await resolve(ident); if (!h) return flash('NOT IN DATABASE'); - fp.set({ name: 'ACTIVE', waypoints: [ + editPlan({ name: 'ACTIVE', waypoints: [ { id: 'PPOS', lat: num(xp.values.lat), lon: num(xp.values.lon), type: 'USR' }, { id: h.id, lat: h.lat, lon: h.lon, type: h.type || 'WPT' }, ] }); @@ -106,12 +126,25 @@ export default function CDU({ xp, vnav: vnavCfg, onVnav }) { if (!legs.length) return flash('NO LEGS'); const tagged = t === 'approach' ? legs.map((l) => (l.seg === 'missed' ? { ...l, missed: true } : { ...l, appr: true })) : legs; const merged = t === 'sid' ? [...tagged, ...wps] : [...wps, ...tagged]; - fp.set({ name: 'ACTIVE', waypoints: merged, activeLeg: t === 'sid' ? 1 : wps.length || 1 }); + editPlan({ name: 'ACTIVE', waypoints: merged, activeLeg: t === 'sid' ? 1 : wps.length || 1 }); flash(`${name} LOADED`); setPage('legs'); } catch { flash('PROC ERROR'); } }; - const exec = () => { if (wps.length >= 2) fp.export(fltNo || 'WEBFPL'); else flash('NEED 2 WAYPOINTS'); }; + // FIX INFO: resolve the reference navaid, then build a fix at radial/distance + const setRef = async (ident) => { const h = await resolve(ident); if (!h) return flash('NOT IN DATABASE'); setFixRef({ id: h.id, lat: h.lat, lon: h.lon }); setScr(''); }; + const insertFix = () => { + if (!fixRef) return flash('SET REF NAVAID'); + const radNum = parseFloat(fixRad); if (!isFinite(radNum)) return flash('SET RADIAL'); + const d = parseFloat(fixDist) || 0; + const pt = d > 0 ? destPoint(fixRef.lat, fixRef.lon, radNum, d) : { lat: fixRef.lat, lon: fixRef.lon }; + const id = `${fixRef.id}${String(Math.round(radNum)).padStart(3, '0')}`.slice(0, 5); + const next = wps.slice(); next.splice(Math.max(1, wps.length - 1), 0, { id, lat: pt.lat, lon: pt.lon, type: 'FIX' }); + editPlan({ name: 'ACTIVE', waypoints: next, activeLeg: flightPlan.activeLeg ?? 1 }); + flash(`FIX ${id} INSERTED`); + }; + + const exec = () => { if (wps.length >= 2) { fp.export(fltNo || 'WEBFPL'); setMod(false); flash('ACTIVATED'); } else flash('NEED 2 WAYPOINTS'); }; const openLoad = async () => setPlans(await fmsList()); // ---- per-page line-select-key handling ---------------------------------- @@ -127,9 +160,10 @@ export default function CDU({ xp, vnav: vnavCfg, onVnav }) { if (page === 'legs') { const i = legPage * LEG_ROWS + r; if (side === 'L') { - if (scr) return insertAt(scr, Math.min(i, wps.length)); - if (del) { if (i < wps.length) fp.remove(i); setDel(false); return; } - if (i >= 1 && i < wps.length) fp.setActive(i); + if (scr) return insertAt(scr, Math.min(i, wps.length)); // insert / stitch a discontinuity + if (del) { if (i < wps.length) { fp.remove(i); setMod(true); } setDel(false); return; } + if (i < wps.length && isDisco(wps[i])) { fp.remove(i); setMod(true); return; } // clear discontinuity + if (i >= 1 && i < wps.length) { fp.setActive(i); setMod(true); } } return; } @@ -150,17 +184,28 @@ export default function CDU({ xp, vnav: vnavCfg, onVnav }) { return; } if (page === 'dir') { if (scr) return directTo(scr); return; } + if (page === 'fix') { + if (side === 'L' && r === 0 && scr) return setRef(scr); + if (side === 'L' && r === 1 && scr) { setFixRad(scr); setScr(''); return; } + if (side === 'L' && r === 2 && scr) { setFixDist(scr); setScr(''); return; } + if (side === 'R' && r === 0) return insertFix(); + return; + } if (page === 'vnav') { - if (vnavPage === 0) { // CLB: trans alt (1L), speed/alt limit (2L) + if (vnavPage === 0) { // CLB: trans alt (1L), speed/alt limits (2L, 3L) if (side === 'L' && r === 0 && scr) { setTransAlt(scr); setScr(''); return; } - if (side === 'L' && r === 1 && scr) { setClbLim(scr); setScr(''); return; } + if (side === 'L' && r === 1 && scr) { setClbLim((a) => [scr, a[1]]); setScr(''); return; } + if (side === 'L' && r === 2 && scr) { setClbLim((a) => [a[0], scr]); setScr(''); return; } + if (side === 'L' && r === 2 && del) { setClbLim((a) => [a[0], '']); setDel(false); return; } } else if (vnavPage === 1) { // CRZ: cruise alt (1L), target speed (1R) if (side === 'L' && r === 0 && scr) { setCrzAlt(scr); setScr(''); return; } if (side === 'R' && r === 0 && scr) { setTgtSpd(scr.replace('/', '')); setScr(''); return; } - } else { // DES: VPA (1R), speed/alt limit (2L), target speed (1L) + } else { // DES: target speed (1L), VPA (1R), speed/alt limits (2L, 3L) if (side === 'R' && r === 0 && scr && onVnav) { const v = parseFloat(scr); if (v >= 2 && v <= 6) onVnav((c) => ({ ...c, fpa: v })); setScr(''); return; } if (side === 'L' && r === 0 && scr) { setTgtSpd(scr.replace('/', '')); setScr(''); return; } - if (side === 'L' && r === 1 && scr) { setDesLim(scr); setScr(''); return; } + if (side === 'L' && r === 1 && scr) { setDesLim((a) => [scr, a[1]]); setScr(''); return; } + if (side === 'L' && r === 2 && scr) { setDesLim((a) => [a[0], scr]); setScr(''); return; } + if (side === 'L' && r === 2 && del) { setDesLim((a) => [a[0], '']); setDel(false); return; } } return; } @@ -195,13 +240,19 @@ export default function CDU({ xp, vnav: vnavCfg, onVnav }) { const rows = []; for (let r = 0; r < LEG_ROWS; r++) { const i = legPage * LEG_ROWS + r; - if (i < wps.length) { const w = wps[i], prev = wps[i - 1]; rows.push({ id: w.id, type: w.type, dtk: prev ? Math.round(brng(prev, w)) : null, d: prev ? distNm(prev, w) : 0, orig: i === 0, act: i === active, missed: w.missed }); } + if (i < wps.length) { + const w = wps[i], prev = wps[i - 1]; + if (isDisco(w)) { rows.push({ disco: true }); continue; } + const linkable = prev && !isDisco(prev); + rows.push({ id: w.id, type: w.type, dtk: linkable ? Math.round(brng(prev, w)) : null, d: linkable ? distNm(prev, w) : 0, orig: i === 0, act: i === active, missed: w.missed }); + } else if (i === wps.length) rows.push({ add: true }); else rows.push({ blank: true }); } body = (
{rows.map((row, r) => ( -
+
{row.blank ? · : row.add ? <----- ENTER WPT + : row.disco ? ─── DISCONTINUITY ─── : (<>{row.id}{row.type}{row.dtk == null ? '---' : `${String(row.dtk).padStart(3, '0')}°`}{row.orig ? 'ORIG' : row.d.toFixed(1)})}
))}
); } else if (page === 'deparr') { @@ -255,6 +306,17 @@ export default function CDU({ xp, vnav: vnavCfg, onVnav }) { {wps.length >= 2 &&
aktiv: {wps[active]?.id}
}
); + } else if (page === 'fix') { + title = 'FIX INFO'; + body = ( +
+
{fixRef?.id || '____'}
+
{fixRad ? `${fixRad}°` : '---°'}
+
{fixDist ? `${fixDist} NM` : '--- NM'}
+
FIX>
+
REF:1L · RADIAL:2L · DIST:3L · INSERT:1R
+
+ ); } else if (page === 'vnav') { title = `ACT VNAV ${VNAV_PAGES[vnavPage]}`; pageNo = `${vnavPage + 1}/3`; if (vnavPage === 0) { // CLB (manual p21-22) @@ -262,8 +324,9 @@ export default function CDU({ xp, vnav: vnavCfg, onVnav }) {
{transAlt || '18000'}
{tgtSpd || '290/.74'}
-
{clbLim}
-
TRANS ALT: 1L · SPD/ALT LIMIT: 2L · NEXT→CRZ
+
{clbLim[0] || '-----'}
+
{clbLim[1] || '-----'}
+
TRANS ALT:1L · SPD/ALT LIM:2L/3L (DEL clears) · NEXT→CRZ
); } else if (vnavPage === 1) { // CRZ (manual p23) @@ -279,8 +342,9 @@ export default function CDU({ xp, vnav: vnavCfg, onVnav }) {
{tgtSpd || '/200'}
{(cfg.fpa || 3).toFixed(1)}°
-
{desLim}
-
TGT SPD: 1L · VPA: 1R (2.0-6.0°) · SPD/ALT: 2L
+
{desLim[0] || '-----'}
+
{desLim[1] || '-----'}
+
TGT SPD:1L · VPA:1R(2.0-6.0°) · SPD/ALT LIM:2L/3L
); } @@ -297,6 +361,9 @@ export default function CDU({ xp, vnav: vnavCfg, onVnav }) { ); } + // MOD/ACT: the title and EXEC light reflect whether edits are pending. + if (title.startsWith('ACT ')) title = (mod ? 'MOD ' : 'ACT ') + title.slice(4); + const Lsk = ({ side, r }) => - +
diff --git a/web/src/styles.css b/web/src/styles.css index 2beabc4..8da0467 100644 --- a/web/src/styles.css +++ b/web/src/styles.css @@ -666,9 +666,12 @@ body { .cdu-k:hover { border-color: #4a525b; } .cdu-k:active { transform: translateY(1px); background: #146b34; color: #fff; } .cdu-k.fn { font-size: 12px; letter-spacing: .5px; color: #9fb0bd; } .cdu-k.fn.arm { background: #7d5a10; color: #fff; border-color: #ffd24a; } -.cdu-k.fn.exec { background: linear-gradient(#1f8f47, #146b34); color: #fff; border-color: #2ee06a; } +.cdu-k.fn.exec { background: linear-gradient(#173a25, #0f2a1a); color: #6f9a7e; border-color: #265a39; } +.cdu-k.fn.exec.arm { background: linear-gradient(#1f8f47, #146b34); color: #fff; border-color: #2ee06a; box-shadow: 0 0 9px rgba(46,224,106,.6); } +.cdu-row.disco { background: rgba(255,176,0,.10); } +.cdu-row.disco .cdu-add { color: #ffb000; } /* page-key row + multi-page FMS bodies */ -.cdu-pages { display: grid; grid-template-columns: repeat(6, 1fr); gap: 6px; margin: 2px 0 6px; } +.cdu-pages { display: grid; grid-template-columns: repeat(4, 1fr); gap: 6px; margin: 2px 0 6px; } .cdu-k.pg { font-size: 11px; letter-spacing: .3px; color: #9fb0bd; padding: 8px 2px; } .cdu-k.pg.on { background: linear-gradient(#0f5a2c, #0b3f1f); color: #9fffc0; border-color: #2ee06a; } .cdu-body { flex: 1; display: flex; flex-direction: column; min-height: 168px; padding-top: 4px; }