diff --git a/web/src/components/CDU.jsx b/web/src/components/CDU.jsx index 8222b35..a7361df 100644 --- a/web/src/components/CDU.jsx +++ b/web/src/components/CDU.jsx @@ -1,17 +1,17 @@ -import React, { useState } from 'react'; -import { num, navSearch } from '../api/useXplane.js'; +import React, { useState, useEffect } from 'react'; +import { num, navSearch, fmsList } from '../api/useXplane.js'; -// FMS as an X-Plane-style CDU/FMC: a green screen showing the active flight plan -// as legs, six line-select keys per side, a scratchpad, and an alphanumeric -// keypad. Edits go through the shared flight plan (the same one the PFD/MFD use). +// Airliner-style FMS / CDU (Collins/Boeing-like, per the X-Plane FMS manual). +// A green screen with six line-select keys (LSK) per side, a scratch pad, page +// keys and an alphanumeric keypad. Everything edits the SHARED flight plan (the +// same one the PFD/MFD/map use), which the FlyWithLua fms-sync mirrors two-way +// into the in-sim FMS — so the app CDU and the aircraft CDU stay synchronized. // -// LSK (left, per row): -// • scratchpad has an ident → insert that waypoint at the row -// • DEL armed → delete the leg at the row -// • otherwise → make that leg the active (magenta) leg (Direct-To) -// EXEC exports the plan to X-Plane as an .fms file. +// Pages: FPLN (origin/dest/flt-no) · LEGS (waypoints) · DEP/ARR (SID/STAR/APPR +// via the CIFP parser) · DIR (direct-to) · VNAV (cruise/speed/path-angle) · +// MENU (load/store .fms). -const R_NM = 3440.065, rad = (d) => d * Math.PI / 180, deg = (r) => r * 180 / Math.PI; +const R_NM = 3440.065, rad = (d) => (d * Math.PI) / 180, deg = (r) => (r * 180) / Math.PI; function distNm(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; @@ -23,63 +23,209 @@ function brng(a, b) { return (deg(Math.atan2(y, x)) + 360) % 360; } -const ROWS = 5; // legs visible per page +const PAGE_KEYS = [['fpln', 'FPLN'], ['legs', 'LEGS'], ['deparr', 'DEP/ARR'], ['dir', 'DIR'], ['vnav', 'VNAV'], ['menu', 'MENU']]; +const LEG_ROWS = 5; -export default function CDU({ xp }) { - const { flightPlan, fp, exportMsg } = xp; +export default function CDU({ xp, vnav: vnavCfg, onVnav }) { + const { flightPlan, fp, exportMsg, command } = xp; const wps = flightPlan.waypoints || []; const active = Math.max(1, Math.min(wps.length - 1, flightPlan.activeLeg ?? 1)); + const dest = [...wps].reverse().find((w) => w.type === 'APT')?.id || ''; + + const [page, setPage] = useState('fpln'); const [scr, setScr] = useState(''); const [del, setDel] = useState(false); const [msg, setMsg] = useState(''); - const [page, setPage] = useState(0); + const [legPage, setLegPage] = useState(0); + const [fltNo, setFltNo] = useState(''); + // DEP/ARR + const [procs, setProcs] = useState(null); + const [cat, setCat] = useState('sids'); // sids | stars | approaches + const [procPage, setProcPage] = useState(0); + // VNAV perf (FMS init values; VPA feeds the shared descent profile) + const cfg = vnavCfg || { fpa: 3, offsetNm: 0, enabled: true }; + const [crzAlt, setCrzAlt] = useState(''); + const [tgtSpd, setTgtSpd] = useState(''); + // saved-plan list (MENU) + const [plans, setPlans] = useState(null); - const pages = Math.max(1, Math.ceil((wps.length + 1) / ROWS)); - const start = page * ROWS; - - const type = (ch) => { setMsg(''); setScr((s) => (s + ch).slice(0, 8)); }; + 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(''); } }; - // resolve an ident and splice it into the plan at `index` - const insertAt = async (ident, index) => { - const hits = await navSearch(ident); - const hit = hits[0]; - if (!hit) { setMsg('NOT IN DATABASE'); return; } - const next = wps.slice(); - next.splice(index, 0, { id: hit.id, lat: hit.lat, lon: hit.lon, type: hit.type || 'WPT', alt: null }); + // fetch destination procedures when entering DEP/ARR + useEffect(() => { + if (page !== 'deparr' || !dest) { return; } + let alive = true; + fetch(`/api/nav/procs?icao=${dest}`).then((r) => (r.ok ? r.json() : null)).then((d) => { if (alive) setProcs(d); }).catch(() => {}); + return () => { alive = false; }; + }, [page, dest]); + + const resolve = async (ident) => (await navSearch(ident))[0] || null; + + 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 }); + 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 }); setScr(''); }; - - const lsk = (rowIdx) => { - const i = start + rowIdx; - if (scr) { insertAt(scr, Math.min(i, wps.length)); return; } - if (del) { if (i < wps.length) fp.remove(i); setDel(false); return; } - if (i >= 1 && i < wps.length) fp.setActive(i); + 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 }); + setScr(''); + }; + const directTo = async (ident) => { + const h = await resolve(ident); if (!h) return flash('NOT IN DATABASE'); + fp.set({ 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' }, + ] }); + command && command('direct'); + setScr(''); flash(`DIRECT ${h.id}`); + }; + // load a SID/STAR/approach's legs (CIFP) into the plan + const loadProc = async (name, trans) => { + const t = { sids: 'sid', stars: 'star', approaches: 'approach' }[cat]; + try { + const r = await fetch(`/api/nav/proc?icao=${procs.icao}&type=${t}&name=${encodeURIComponent(name)}&trans=${encodeURIComponent(trans || '')}`); + const legs = r.ok ? await r.json() : []; + 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 }); + flash(`${name} LOADED`); setPage('legs'); + } catch { flash('PROC ERROR'); } }; - const exec = () => { if (wps.length >= 2) fp.export('WEBFPL'); else setMsg('NEED 2 WAYPOINTS'); }; + const exec = () => { if (wps.length >= 2) fp.export(fltNo || 'WEBFPL'); else flash('NEED 2 WAYPOINTS'); }; + const openLoad = async () => setPlans(await fmsList()); - // build the visible rows - const rows = []; - for (let r = 0; r < ROWS; r++) { - const i = start + r; - if (i < wps.length) { - const w = wps[i], prev = wps[i - 1]; - const d = prev ? distNm(prev, w) : 0; - const dtk = prev ? Math.round(brng(prev, w)) : null; - rows.push({ i, id: w.id, type: w.type, d, dtk, orig: i === 0, act: i === active }); - } else if (i === wps.length) { - rows.push({ i, empty: true }); - } else { - rows.push({ i, blank: true }); + // ---- per-page line-select-key handling ---------------------------------- + const onLsk = (side, r) => { + if (page === 'fpln') { + if (side === 'L' && r === 0 && scr) return setOrigin(scr); + if (side === 'R' && r === 0 && scr) return setDest(scr); + if (side === 'R' && r === 1) { if (scr) { setFltNo(scr); setScr(''); } return; } + if (side === 'L' && r === 4) return setPage('menu'); + if (side === 'R' && r === 4) return setPage('vnav'); + return; } + 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); + } + return; + } + if (page === 'deparr') { + if (side === 'R' && r < 3) { setCat(['sids', 'stars', 'approaches'][r]); setProcPage(0); return; } + if (side === 'L') { + const list = (procs && procs[cat]) || []; + const p = list[procPage * 5 + r]; + if (p) return loadProc(p.name, p.transitions && p.transitions[0]); + } + return; + } + if (page === 'dir') { if (scr) return directTo(scr); return; } + if (page === 'vnav') { + if (side === 'L' && r === 0 && scr) { setCrzAlt(scr); setScr(''); return; } + if (side === 'R' && r === 0 && scr) { setTgtSpd(scr.replace('/', '')); setScr(''); return; } + if (side === 'R' && r === 2 && scr && onVnav) { const v = parseFloat(scr); if (v >= 2 && v <= 6) onVnav((c) => ({ ...c, fpa: v })); setScr(''); return; } + return; + } + if (page === 'menu') { + if (side === 'L' && r === 0) return openLoad(); + if (side === 'L' && r === 1) return exec(); + if (plans && side === 'L') { const n = plans[(r - 2)]; if (n) { fp.load(n); setPlans(null); flash(`${n} LOADED`); } } + } + }; + + // ---- page body (12 lines, mapped to LSK 1L..6L / 1R..6R) ---------------- + const A = 'ABCDEFGHIJKLMNOPQRSTUVWXYZ'.split(''); + const KEYS = [A.slice(0, 7), A.slice(7, 14), A.slice(14, 21), A.slice(21, 26).concat(['/', ' ']), ['1', '2', '3', '4', '5'], ['6', '7', '8', '9', '0']]; + + const legPages = Math.max(1, Math.ceil((wps.length + 1) / LEG_ROWS)); + const procList = (procs && procs[cat]) || []; + const procPages = Math.max(1, Math.ceil(procList.length / 5)); + + let title = 'ACT FPLN', pageNo = '1/1', body = null; + if (page === 'fpln') { + body = ( +
+
{wps[0]?.id || '____'}
+
{dest || '____'}
+
{fltNo || '--------'}
+
<ROUTE MENUVNAV>
+
+ ); + } else if (page === 'legs') { + title = del ? 'DELETE' : 'ACT LEGS'; pageNo = `${legPage + 1}/${legPages}`; + 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 }); } + 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.id}{row.type}{row.dtk == null ? '---' : `${String(row.dtk).padStart(3, '0')}°`}{row.orig ? 'ORIG' : row.d.toFixed(1)})} +
))}
); + } else if (page === 'deparr') { + title = `${dest || '----'} ${cat === 'sids' ? 'DEPART' : cat === 'stars' ? 'ARRIVAL' : 'APPROACH'}`; pageNo = `${procPage + 1}/${procPages}`; + const shown = procList.slice(procPage * 5, procPage * 5 + 5); + body = ( +
+
SID>STAR>APPR>
+ {!procs &&
{dest ? 'loading…' : 'set DEST on FPLN'}
} + {procs && shown.length === 0 &&
none
} + {shown.map((p, i) =>
<{p.name}{p.transitions?.length ? {p.transitions.length} TR : null}
)} +
+ ); + } else if (page === 'dir') { + title = 'DIRECT TO'; + body = ( +
+
Ident eingeben, dann LSK 1L
+
{scr || '____'}
+ {wps.length >= 2 &&
aktiv: {wps[active]?.id}
} +
+ ); + } else if (page === 'vnav') { + title = 'ACT VNAV'; + body = ( +
+
{crzAlt || '-----'}
+
{tgtSpd || '---'}
+
{(cfg.fpa || 3).toFixed(1)}°
+
CRZ ALT: LSK1L · TGT SPD: LSK1R · VPA: LSK3R
+
+ ); + } else if (page === 'menu') { + title = plans ? 'CO ROUTE LIST' : 'ROUTE MENU'; + body = plans ? ( +
{plans.length === 0 ?
keine .fms
: plans.slice(0, 5).map((n) =>
<{n}.fms
)}
+ ) : ( +
+
<LOAD (CO ROUTE)
+
<STORE / EXPORT
+
STORE schreibt {fltNo || 'WEBFPL'}.fms
+
+ ); } - const A = 'ABCDEFGHIJKLMNOPQRSTUVWXYZ'.split(''); - const KEYS = [A.slice(0, 7), A.slice(7, 14), A.slice(14, 21), A.slice(21, 26).concat([' ']), ['1', '2', '3', '4', '5'], ['6', '7', '8', '9', '0']]; - - const Lsk = ({ side, r }) => + ))} + +
- - + + @@ -123,9 +262,7 @@ export default function CDU({ xp }) {
{KEYS.map((rowK, ri) => (
- {rowK.map((k) => ( - - ))} + {rowK.map((k) => )}
))}
diff --git a/web/src/styles.css b/web/src/styles.css index 881bed6..2beabc4 100644 --- a/web/src/styles.css +++ b/web/src/styles.css @@ -667,6 +667,26 @@ body { .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; } +/* page-key row + multi-page FMS bodies */ +.cdu-pages { display: grid; grid-template-columns: repeat(6, 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; } +.cdu-cols2 { display: flex; flex-direction: column; } +.cdu-row.dim .cdu-wpt { color: #6f9a7e; } .cdu-row.dim .cdu-wpt i { color: #14502a; } +.cdu-fpln, .cdu-vnav { display: flex; flex-direction: column; gap: 10px; } +.cdu-fl { display: flex; flex-direction: column; } +.cdu-fl.r { align-items: flex-end; } +.cdu-fl label { color: #1f9d52; font-size: 11px; letter-spacing: 1px; } +.cdu-fl b { color: #fff; font-size: 19px; font-weight: 600; } +.cdu-fl.bot { margin-top: auto; flex-direction: row; justify-content: space-between; } +.cdu-link { color: #34e06a; font-size: 13px; } .cdu-link.r { text-align: right; } +.cdu-deparr, .cdu-dir, .cdu-menu { display: flex; flex-direction: column; gap: 4px; } +.cdu-tabs { display: flex; justify-content: flex-end; gap: 12px; margin-bottom: 4px; } +.cdu-tabs span { color: #1f9d52; font-size: 12px; } .cdu-tabs span.on { color: #9fffc0; font-weight: 700; } +.cdu-prow { display: flex; justify-content: space-between; align-items: baseline; font-size: 16px; color: #fff; padding: 3px 0; border-bottom: 1px solid #06250f; } +.cdu-prow i { color: #1f9d52; font-style: normal; font-size: 11px; } +.cdu-note { color: #1f9d52; font-size: 13px; padding: 4px 0; } .cdu-note.small { color: #167d3f; font-size: 11px; margin-top: auto; } /* MFD */ .mfd { display: flex; gap: 24px; align-items: center; flex-wrap: wrap; justify-content: center; width: 100%; }