Fix 3rd autopilot UI (Bezel APController) + wire dead PATHWAY/APTSIGNS keys

Line-by-line control audit. APController (the MFD left-bezel autopilot mode
controller) still decoded the unreliable autopilot_state bitfield — the same bug
already fixed in AutopilotPanel and KAP140, missed in the third AP UI. Now reads
per-mode *_status datarefs so every mode key lights correctly.

PATHWAY and APTSIGNS softkeys were dead (sim-mirror only). PATHWAY now draws the
flight-plan route on the synthetic-vision terrain; APTSIGNS toggles the runway
labels. Threaded via App svtOpts → PFD → SVT.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
2026-06-04 02:05:06 +02:00
parent 5f1339f8b3
commit 3d6d3f710e
4 changed files with 60 additions and 22 deletions
+5 -1
View File
@@ -74,6 +74,9 @@ export default function App() {
// 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 });
// Synthetic-vision display options (PFD submenu): PATHWAY draws the flight-plan
// route on the 3D terrain; APTSIGNS shows runway/airport labels.
const [svtOpts, setSvtOpts] = useState({ pathway: true, aptSigns: true });
// 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.
@@ -139,6 +142,7 @@ export default function App() {
<main className="screen">
{tab === 'pfd' && (
<Bezel variant="pfd" xp={xp} knobMode={knobMode} svt3d={svt3d} onToggleSvt={() => setSvt3d((v) => !v)}
svtOpts={svtOpts} onSvtOpt={setSvtOpts}
inset={inset} onSetInset={setInset} insetMode={insetMode} onInsetMode={setInsetMode}
nrst={nrst} onToggleNrst={() => toggleWin('nrst')} onDirect={() => toggleWin('dto')}
tmr={tmr} onToggleTmr={() => toggleWin('tmr')} dme={dme} onToggleDme={() => toggleWin('dme')}
@@ -147,7 +151,7 @@ export default function App() {
<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} vnav={vnavCfg} />
minimums={minimums} onMinimums={setMinimums} flightPlan={xp.flightPlan} fp={xp.fp} vnav={vnavCfg} svtOpts={svtOpts} />
{dialogs}
</Bezel>
)}
+22 -18
View File
@@ -34,10 +34,7 @@ const MFD_MENU = {
};
const KG_PER_GAL = 2.72; // fuel totalizer steps in US gallons (matches the EIS readout)
// autopilot_state bitfield (best-effort; tweak per aircraft)
const AP_BITS = { fd: 1 << 0, hdg: 1 << 1, vs: 1 << 4, flc: 1 << 6, nav: 1 << 8, apr: 1 << 9, vnav: 1 << 11, altHold: 1 << 14, bc: 1 << 18 };
export default function Bezel({ variant = 'pfd', xp, svt3d, onToggleSvt, inset, onSetInset, insetMode, onInsetMode, nrst, onToggleNrst, tmr, onToggleTmr, dme, onToggleDme, alerts, onToggleAlerts, onDirect, onProc, onFpl, onClr, onFms, mapMode, onMapMode, altHpa, onAltUnit, obs, onObs, knobMode = 'arrows', children }) {
export default function Bezel({ variant = 'pfd', xp, svt3d, onToggleSvt, svtOpts, onSvtOpt, inset, onSetInset, insetMode, onInsetMode, nrst, onToggleNrst, tmr, onToggleTmr, dme, onToggleDme, alerts, onToggleAlerts, onDirect, onProc, onFpl, onClr, onFms, mapMode, onMapMode, altHpa, onAltUnit, obs, onObs, knobMode = 'arrows', children }) {
const u = variant === 'mfd' ? 'mfd' : 'pfd'; // command prefix
const fire = (suffix) => xp && xp.command(`${u}_${suffix}`);
const [page, setPage] = useState('root'); // softkey menu page
@@ -83,6 +80,8 @@ export default function Bezel({ variant = 'pfd', xp, svt3d, onToggleSvt, inset,
if (label === 'PFD') setPage('pfd');
else if (label === 'BACK') setPage({ xpdrcode: 'xpdr', altunit: 'pfd' }[page] || 'root');
else if (label === 'SYN TERR') onToggleSvt && onToggleSvt();
else if (label === 'PATHWAY') onSvtOpt && onSvtOpt((o) => ({ ...o, pathway: !o.pathway })); // route on the 3D terrain
else if (label === 'APTSIGNS') onSvtOpt && onSvtOpt((o) => ({ ...o, aptSigns: !o.aptSigns })); // runway/airport labels in SVT
else if (label === 'ALT UNIT') setPage('altunit');
else if (label === 'IN') { onAltUnit && onAltUnit(false); setPage('pfd'); }
else if (label === 'HPA') { onAltUnit && onAltUnit(true); setPage('pfd'); }
@@ -120,7 +119,8 @@ export default function Bezel({ variant = 'pfd', xp, svt3d, onToggleSvt, inset,
|| (label === 'TERRAIN' && mapMode?.terrain) || (label === 'OSM' && mapMode?.base === 'osm')
|| (label === 'DCLTR' && mapMode?.dcltr > 0) || (label === 'AIRWAYS' && mapMode?.airways)
|| (label === 'AIRSPACE' && mapMode?.airspace);
return (label === 'SYN TERR' && svt3d) || (label === 'INSET' && inset) || (label === 'NRST' && nrst) || (label === 'TMR/REF' && tmr)
return (label === 'SYN TERR' && svt3d) || (label === 'PATHWAY' && svtOpts?.pathway) || (label === 'APTSIGNS' && svtOpts?.aptSigns)
|| (label === 'INSET' && inset) || (label === 'NRST' && nrst) || (label === 'TMR/REF' && tmr)
|| (label === 'DME' && dme) || (label === 'OBS' && obs) || (label === 'CAUTION' && (alerts || hasAlerts))
|| (label === 'STBY' && xpdrMode === 1) || (label === 'ON' && xpdrMode === 2) || (label === 'ALT' && xpdrMode === 3)
|| (label === 'IN' && !altHpa) || (label === 'HPA' && altHpa)
@@ -195,26 +195,30 @@ function BtnG({ fire, cmd, onClick, children }) {
}
// Autopilot mode controller (left bezel of the MFD). Buttons fire real X-Plane
// commands; active modes light up from autopilot_state / servos_on.
// commands; active modes light from the per-mode *_status datarefs (off/armed/
// active) — the same reliable source as the PFD bar and the AutopilotPanel, not
// the autopilot_state bitfield (whose bit positions don't match X-Plane).
function APController({ xp }) {
const st = num(xp.values.apState);
const on = (bit) => (st & bit) !== 0;
const eng = num(xp.values.apEngaged) > 0;
const V = xp.values;
const lit = (k) => num(V[k]) > 0;
const apMode = num(V.apMode);
const eng = num(V.apEngaged) > 0 || apMode >= 2;
const fdOn = apMode >= 1 || eng;
const B = ({ label, cmd, active }) => (
<button className={`ap-key ${active ? 'on' : ''}`} onClick={() => xp.command(cmd)}>{label}</button>
);
return (
<div className="ap-controller">
<B label="AP" cmd="apToggle" active={eng} />
<B label="FD" cmd="fdToggle" active={on(AP_BITS.fd)} />
<B label="HDG" cmd="hdg" active={on(AP_BITS.hdg)} />
<B label="ALT" cmd="altHold" active={on(AP_BITS.altHold)} />
<B label="NAV" cmd="nav" active={on(AP_BITS.nav)} />
<B label="VNV" cmd="vnav" active={on(AP_BITS.vnav)} />
<B label="APR" cmd="apr" active={on(AP_BITS.apr)} />
<B label="BC" cmd="backCourse" active={on(AP_BITS.bc)} />
<B label="VS" cmd="vs" active={on(AP_BITS.vs)} />
<B label="FLC" cmd="flc" active={on(AP_BITS.flc)} />
<B label="FD" cmd="fdToggle" active={fdOn} />
<B label="HDG" cmd="hdg" active={lit('hdgStatus')} />
<B label="ALT" cmd="altHold" active={lit('altStatus')} />
<B label="NAV" cmd="nav" active={lit('navStatus') || lit('gpssStatus')} />
<B label="VNV" cmd="vnav" active={lit('vnavStatus')} />
<B label="APR" cmd="apr" active={lit('aprStatus')} />
<B label="BC" cmd="backCourse" active={lit('bcStatus')} />
<B label="VS" cmd="vs" active={lit('vsStatus')} />
<B label="FLC" cmd="flc" active={lit('flcStatus')} />
<B label="NOSE UP" cmd="noseUp" />
<B label="NOSE DN" cmd="noseDown" />
</div>
+2 -2
View File
@@ -104,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({ 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 }) {
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, svtOpts }) {
const wrapRef = useRef(null);
const svgRef = useRef(null);
const [box, setBox] = useState(null);
@@ -164,7 +164,7 @@ export default function PFD({ xp, values: V, command, connected = true, svt = tr
<div className="pfd-wrap" ref={wrapRef}>
{svt && box && (
<div className="svt-pos" style={box}>
<Suspense fallback={<div className="svt-fallback" />}><SVT values={V} /></Suspense>
<Suspense fallback={<div className="svt-fallback" />}><SVT values={V} flightPlan={flightPlan} opts={svtOpts} /></Suspense>
</div>
)}
{inset && insetBox && (
+31 -1
View File
@@ -83,11 +83,27 @@ function cameraPitchForAircraft(aircraftPitchDeg) {
return Math.max(60, Math.min(85, pitch));
}
export default function SVT({ values }) {
// Flight-plan route as a GeoJSON LineString (drawn on the terrain for PATHWAY).
function routeGeo(plan) {
const wps = plan?.waypoints || [];
return { type: 'Feature', geometry: { type: 'LineString', coordinates: wps.map((w) => [w.lon, w.lat]) } };
}
// Toggle the PATHWAY route line and APTSIGNS runway labels (default on).
function setSvtVisibility(map, opts) {
if (!map) return;
const set = (id, vis) => { try { if (map.getLayer(id)) map.setLayoutProperty(id, 'visibility', vis ? 'visible' : 'none'); } catch { /* not ready */ } };
set('fpl-line', !opts || opts.pathway !== false);
['rwy-fill', 'rwy-line', 'rwy-num'].forEach((id) => set(id, !opts || opts.aptSigns !== false));
}
export default function SVT({ values, flightPlan, opts }) {
const elRef = useRef(null);
const mapRef = useRef(null);
const dataRef = useRef(values);
dataRef.current = values;
const planRef = useRef(flightPlan); planRef.current = flightPlan;
const optsRef = useRef(opts); optsRef.current = opts;
useEffect(() => {
let map;
@@ -131,6 +147,12 @@ export default function SVT({ values }) {
},
paint: { 'text-color': '#fff', 'text-halo-color': '#000', 'text-halo-width': 1.4 },
});
// PATHWAY: the active flight-plan route, draped magenta on the terrain.
map.addSource('fplroute', { type: 'geojson', data: routeGeo(planRef.current) });
map.addLayer({ id: 'fpl-line', type: 'line', source: 'fplroute',
layout: { 'line-cap': 'round', 'line-join': 'round' },
paint: { 'line-color': '#ff20ff', 'line-width': 3, 'line-opacity': 0.9 } });
setSvtVisibility(map, optsRef.current); // honour PATHWAY / APTSIGNS from the start
let last = null;
const refresh = async () => {
const v = dataRef.current, lat = num(v.lat), lon = num(v.lon);
@@ -186,6 +208,14 @@ export default function SVT({ values }) {
return () => { cancelAnimationFrame(raf); clearInterval(rwyTimer); map.remove(); mapRef.current = null; };
}, []); // eslint-disable-line
// Keep the PATHWAY route in sync with the flight plan, and apply PATHWAY /
// APTSIGNS visibility when the softkeys toggle them.
useEffect(() => {
const m = mapRef.current;
if (m && m.getSource && m.getSource('fplroute')) { try { m.getSource('fplroute').setData(routeGeo(flightPlan)); } catch { /* not ready */ } }
}, [flightPlan]);
useEffect(() => { setSvtVisibility(mapRef.current, opts); }, [opts]); // eslint-disable-line
// Bank: rotate the whole terrain canvas opposite to aircraft roll; scale up so
// the corners stay covered while rotated.
const roll = num(values.roll);