diff --git a/server/flightplan.js b/server/flightplan.js index 3b24ecf..4a7a6fa 100644 --- a/server/flightplan.js +++ b/server/flightplan.js @@ -18,7 +18,7 @@ export function setPlan(next) { const wps = Array.isArray(next?.waypoints) ? next.waypoints .filter((w) => isFinite(w.lat) && isFinite(w.lon)) - .map((w) => ({ id: String(w.id || 'WPT'), lat: +w.lat, lon: +w.lon, type: w.type || 'WPT', alt: w.alt ?? null, ...(w.dsgn != null ? { dsgn: !!w.dsgn } : {}), ...(w.appr ? { appr: true } : {}) })) + .map((w) => ({ id: String(w.id || 'WPT'), lat: +w.lat, lon: +w.lon, type: w.type || 'WPT', alt: w.alt ?? null, ...(w.dsgn != null ? { dsgn: !!w.dsgn } : {}), ...(w.appr ? { appr: true } : {}), ...(w.missed ? { missed: true } : {}) })) : []; const wantLeg = Number.isFinite(next?.activeLeg) ? next.activeLeg : 1; plan = { name: next?.name || 'ACTIVE', waypoints: wps, activeLeg: Math.max(1, Math.min(wps.length - 1, wantLeg)) || 1 }; diff --git a/server/procedures.js b/server/procedures.js index d47042b..a6b8dd5 100644 --- a/server/procedures.js +++ b/server/procedures.js @@ -121,6 +121,10 @@ export function procedureLegs(icao, type, name, trans) { const out = []; const seen = new Set(); + // For approaches, the runway threshold is the Missed-Approach Point: legs after + // it are the missed-approach segment. We tag (rather than drop) them so the + // FMS can hold them un-sequenced and "Activate Missed Approach" can fly them. + let inMissed = false; for (const leg of seq) { if (!leg.fix) continue; // heading/altitude legs w/o a fix if (seen.has(leg.fix)) continue; // de-dupe repeated fixes @@ -133,9 +137,10 @@ export function procedureLegs(icao, type, name, trans) { } if (!pt) continue; // unresolved fix → skip seen.add(leg.fix); - out.push({ id: leg.fix, lat: pt.lat, lon: pt.lon, type: isRwy ? 'APT' : 'WPT', alt: leg.alt }); - // An approach ends at the runway threshold — drop the missed-approach legs. - if (TYPE === 'APPCH' && isRwy) break; + const wp = { id: leg.fix, lat: pt.lat, lon: pt.lon, type: isRwy ? 'APT' : 'WPT', alt: leg.alt }; + if (TYPE === 'APPCH') wp.seg = inMissed ? 'missed' : 'approach'; + out.push(wp); + if (TYPE === 'APPCH' && isRwy) inMissed = true; // everything past the runway = missed } return out; } diff --git a/web/src/components/MapView.jsx b/web/src/components/MapView.jsx index cf07afb..94f62fe 100644 --- a/web/src/components/MapView.jsx +++ b/web/src/components/MapView.jsx @@ -175,7 +175,9 @@ export default function MapView({ values, flightPlan, fp, inset = false, hud = t const refreshAirspace = async () => { const layer = aspLayerRef.current; if (!layer) return; - if (!aspOnRef.current || map.getZoom() < 6) { layer.clearLayers(); return; } + // DCLTR-2 and above declutter Special-Use Airspace (manual p.56), so hide + // the airspace overlay at those levels even when the AIRSPACE key is on. + if (!aspOnRef.current || map.getZoom() < 6 || (dcltrRef.current || 0) >= 2) { layer.clearLayers(); return; } const b = map.getBounds(); try { const res = await fetch(`/api/airspace/bbox?s=${b.getSouth()}&w=${b.getWest()}&n=${b.getNorth()}&e=${b.getEast()}&limit=400`); @@ -309,6 +311,7 @@ export default function MapView({ values, flightPlan, fp, inset = false, hud = t if (!map) return; if (dcltr > 0) navLayerRef.current?.clearLayers(); else map.fire('moveend'); // triggers refreshNav to redraw symbols + refreshAirspaceRef.current && refreshAirspaceRef.current(); // re-eval SUA declutter (DCLTR-2) }, [dcltr]); // eslint-disable-line // Smooth ownship motion. The sim streams position/heading at ~10 Hz; setting diff --git a/web/src/components/Proc.jsx b/web/src/components/Proc.jsx index f6c363c..5111e2d 100644 --- a/web/src/components/Proc.jsx +++ b/web/src/components/Proc.jsx @@ -25,6 +25,7 @@ export default function Proc({ xp, onClose }) { const [selProc, setSelProc] = useState(null); // { name, transitions } const [selTrans, setSelTrans] = useState(''); const [legs, setLegs] = useState([]); + const [note, setNote] = useState(''); // Fetch the procedure summary whenever the airport changes. useEffect(() => { @@ -54,12 +55,27 @@ export default function Proc({ xp, onClose }) { const load = () => { if (!legs.length) return; const existing = wps.slice(); - // Departures go to the front, arrivals/approaches to the end. - const merged = cat === 'departure' ? [...legs, ...existing] : [...existing, ...legs]; + // Approaches carry the missed-approach segment too (server-tagged via `seg`): + // flag approach legs `appr` and missed legs `missed` so the FMS can activate + // each on demand. Departures go to the front, arrivals/approaches to the end. + const tagged = cat === 'approach' + ? legs.map((l) => (l.seg === 'missed' ? { ...l, missed: true } : { ...l, appr: true })) + : legs; + const merged = cat === 'departure' ? [...tagged, ...existing] : [...existing, ...tagged]; fp.set({ name: 'ACTIVE', waypoints: merged, activeLeg: cat === 'departure' ? 1 : existing.length || 1 }); onClose(); }; + // Activate a segment already loaded in the plan, like the real PROC menu. + // setActive(i) makes the leg ENDING at waypoint i the magenta (active) leg. + const activate = (find, label) => { + const i = find(flightPlan?.waypoints || []); + if (i > 0) { fp.setActive(i); onClose(); } + else setNote(`Kein ${label} im Flugplan — erst SELECT APPROACH → LOAD`); + }; + const firstIdx = (pred) => (ws) => ws.findIndex(pred); + const lastIdx = (pred) => (ws) => { for (let i = ws.length - 1; i >= 0; i--) if (pred(ws[i])) return i; return -1; }; + const catLabel = CATS.find((c) => c.id === cat).label; // The PDF's action menu. SELECT … opens our picker for that category; @@ -74,13 +90,14 @@ export default function Proc({ xp, onClose }) {
e.stopPropagation()}>
PROCEDURES
- {item('ACTIVATE VECTOR-TO-FINAL', () => {})} - {item('ACTIVATE APPROACH', () => {})} - {item('ACTIVATE MISSED APPROACH', () => {})} + {item('ACTIVATE VECTOR-TO-FINAL', () => activate(lastIdx((w) => w.appr), 'Approach'))} + {item('ACTIVATE APPROACH', () => activate(firstIdx((w) => w.appr), 'Approach'))} + {item('ACTIVATE MISSED APPROACH', () => activate(firstIdx((w) => w.missed), 'Missed Approach'))} {item('SELECT APPROACH', () => sel('approach'), true)} {item('SELECT ARRIVAL', () => sel('arrival'))} {item('SELECT DEPARTURE', () => sel('departure'))}
+ {note &&
{note}
}
); @@ -120,8 +137,8 @@ export default function Proc({ xp, onClose }) {
{legs.length} FIXES
{legs.map((l, i) => ( -
- {l.id}{l.alt ? {l.alt}ft : null} +
+ {l.id}{l.alt ? {l.alt}ft : null}{l.seg === 'missed' ? MA : null}
))}
diff --git a/web/src/styles.css b/web/src/styles.css index de274d4..a95f6a5 100644 --- a/web/src/styles.css +++ b/web/src/styles.css @@ -382,6 +382,9 @@ body { .proc-empty { color: #6f808d; font-size: 11px; padding: 8px; } .proc-leg { display: flex; align-items: baseline; gap: 8px; padding: 5px 8px; border-bottom: 1px solid #11161b; font-size: 13px; } .proc-leg b { color: #0ff; } .proc-leg u { color: #39d3c0; font-size: 10px; text-decoration: none; margin-left: auto; } +.proc-leg.missed b { color: #8aa0ad; } /* missed-approach legs shown dimmed */ +.proc-leg .missed-tag { color: #6f808d; font-style: normal; font-size: 9px; border: 1px solid #2a3640; border-radius: 2px; padding: 0 3px; margin-left: 6px; } +.proc-note { color: #ffce46; font-size: 11px; padding: 8px 12px; border-top: 1px solid #11161b; } /* G1000 vector nav symbology drawn from X-Plane's own nav data */ .nav-divicon { background: none; border: none; } .nav-sym { position: relative; width: 18px; height: 18px; }