Files
xplane-cockpit/web/src/components/Bezel.jsx
T
karim 9aba24978b Auto-install Lua, smooth all panels, airspace overlay + launcher region picker
FlyWithLua auto-install: bridge drops fms-sync/ui-sync/terrain-probe into
X-Plane's FlyWithLua Scripts dir on startup and self-updates (content-compare).
Graceful when no X-Plane / no FlyWithLua. /api/lua/install + status in health.
Desktop app bundles the scripts and passes LUA_SRC_DIR to the sidecar.

Smoothing: shared useEased/useEasedAngle hook (api/ease.js) with render-bail on
settle. VFR steam gauges now interpolate to 60fps instead of stepping at the
~10Hz value stream. MFD ownship no longer vibrates — position/heading eased in a
single rAF loop, follow-pan without animated-panTo pile-up (pauses on range zoom).

Airspace overlay: server/airspace.js loads per-region GeoJSON, classifies
(B/C/D/TMA/CTR/MOA/Restricted/Prohibited/Danger), bbox query, and downloads
regions on demand — FAA (US, key-free) and OpenAIP (Europe, user key). New
AIRSPACE softkey draws chart-coloured boundaries (B blue, C magenta, D dashed),
non-interactive so map-clicks still drop waypoints. Launcher gains a "Lufträume"
section to pick/download regions via the running bridge.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-02 13:57:50 +02:00

278 lines
15 KiB
React
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import React, { useState } from 'react';
import { num, systemAlerts } from '../api/useXplane.js';
// The physical GDU bezel of X-Plane's "XPLANE 1000" (its G1000 clone):
// title bar, knob columns, the 12 softkeys along the bottom — and, on the MFD,
// the autopilot mode controller built into the left bezel (just like the sim).
//
// EVERY control is clickable and fires the matching real X-Plane command
// (sim/GPS/g1000n1_* on the PFD, g1000n3_* on the MFD) via xp.command().
//
// The PFD softkeys are a two-level menu, exactly like the real unit:
// root → [INSET · PFD · CDI · DME · XPDR · IDENT · TMR/REF · NRST · CAUTION]
// PFD → [PATHWAY · SYN TERR · HRZN HDG · APTSIGNS · … · BACK]
// SYN TERR toggles the 3D synthetic-vision terrain on/off.
const PFD_MENU = {
root: ['', 'INSET', '', 'PFD', '', 'CDI', 'DME', 'XPDR', 'IDENT', 'TMR/REF', 'NRST', 'CAUTION'],
pfd: ['PATHWAY', 'SYN TERR', 'HRZN HDG', 'APTSIGNS', 'ALT UNIT', '', '', '', '', '', '', 'BACK'],
// ALT UNIT submenu: barometric pressure units (inHg / hectopascal), like the manual.
altunit: ['IN', 'HPA', '', '', '', '', '', '', '', '', '', 'BACK'],
// XPDR submenu: standby/on/alt modes, VFR (1200), CODE entry, IDENT.
xpdr: ['STBY', 'ON', 'ALT', 'VFR', '', 'CODE', 'IDENT', '', '', '', '', 'BACK'],
// CODE entry turns the softkeys into the octal squawk keypad (digits 07).
xpdrcode: ['0', '1', '2', '3', '4', '5', '6', '7', 'BKSP', '', 'BACK', ''],
// INSET submenu: on/off, declutter, base layer, OFF, back.
inset: ['INSET', 'DCLTR', '', 'TOPO', 'TERRAIN', '', '', '', '', '', 'OFF', 'BACK'],
};
// MFD softkeys are a two-level menu like the real unit. MAP opens the Map-Opt
// page; TOPO/TERRAIN/OSM switch the base map; BACK returns. (OSM is our tuned
// extra layer in an otherwise-empty slot.)
const MFD_MENU = {
root: ['ENGINE', 'MAP', '', '', '', '', '', '', '', 'DCLTR', '', ''],
mapopt: ['TRAFFIC', 'PROFILE', 'TOPO', 'TERRAIN', 'AIRWAYS', 'AIRSPACE', 'NEXRAD', 'OSM', '', '', 'BACK', ''],
engine: ['DEC FUEL', 'INC FUEL', 'RST FUEL', '', '', '', '', '', '', '', 'BACK', ''],
};
// 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 }) {
const u = variant === 'mfd' ? 'mfd' : 'pfd'; // command prefix
const fire = (suffix) => xp && xp.command(`${u}_${suffix}`);
const [page, setPage] = useState('root'); // softkey menu page
const [squawk, setSquawk] = useState(''); // XPDR code being typed
const menu = variant === 'mfd' ? MFD_MENU : PFD_MENU;
let keys = menu[page] || menu.root;
// OBS appears in the PFD root only when a flight-plan leg is active (like the real unit)
const hasLeg = (xp?.flightPlan?.waypoints?.length || 0) >= 2;
if (variant !== 'mfd' && page === 'root' && hasLeg) { keys = keys.slice(); keys[4] = 'OBS'; }
const setBase = (b) => onMapMode && onMapMode((m) => ({ ...m, base: m.base === b ? 'dark' : b }));
const xpdrMode = num(xp?.values?.xpdrMode);
const setMode = (m) => xp && xp.setDataref('xpdrMode', m);
const hasAlerts = systemAlerts(xp?.values).length > 0; // lights the CAUTION key
const typeDigit = (d) => {
const next = (squawk + d).slice(-4);
setSquawk(next);
if (next.length === 4) { // 4 octal digits → write & exit
xp && xp.setDataref('xpdrCode', parseInt(next, 10));
setSquawk(''); setPage('xpdr');
}
};
const onSoftkey = (i, label) => {
fire(`softkey${i + 1}`); // mirror to the in-sim G1000
// declutter cycles through 4 levels (0=all … 3=flight plan only), like the manual
const cycleDcltr = (setter) => setter && setter((m) => ({ ...m, dcltr: (((m.dcltr || 0) + 1) % 4) }));
if (variant === 'mfd') {
if (label === 'MAP') setPage('mapopt');
else if (label === 'ENGINE') setPage('engine');
else if (label === 'BACK') setPage('root');
else if (label === 'TOPO') setBase('topo'); // relief on/off
else if (label === 'TERRAIN') onMapMode && onMapMode((m) => ({ ...m, terrain: !m.terrain })); // awareness overlay (independent)
else if (label === 'OSM') setBase('osm');
else if (label === 'DCLTR') cycleDcltr(onMapMode);
else if (label === 'AIRWAYS') onMapMode && onMapMode((m) => ({ ...m, airways: !m.airways }));
else if (label === 'AIRSPACE') onMapMode && onMapMode((m) => ({ ...m, airspace: !m.airspace }));
} else {
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 === 'ALT UNIT') setPage('altunit');
else if (label === 'IN') { onAltUnit && onAltUnit(false); setPage('pfd'); }
else if (label === 'HPA') { onAltUnit && onAltUnit(true); setPage('pfd'); }
else if (label === 'INSET') {
if (page === 'root') { onSetInset && onSetInset(true); setPage('inset'); }
else onSetInset && onSetInset(!inset); // toggle from within the submenu
}
else if (label === 'OFF') { onSetInset && onSetInset(false); setPage('root'); }
else if (label === 'DCLTR') cycleDcltr(onInsetMode);
else if (label === 'TOPO') onInsetMode && onInsetMode((m) => ({ ...m, base: m.base === 'topo' ? 'dark' : 'topo' }));
else if (label === 'TERRAIN') onInsetMode && onInsetMode((m) => ({ ...m, terrain: !m.terrain }));
else if (label === 'NRST') onToggleNrst && onToggleNrst();
else if (label === 'TMR/REF') onToggleTmr && onToggleTmr();
else if (label === 'DME') onToggleDme && onToggleDme();
else if (label === 'OBS') onObs && onObs(); // suspend / OBS mode (also fires the sim softkey above)
else if (label === 'CAUTION') onToggleAlerts && onToggleAlerts();
else if (label === 'XPDR') setPage('xpdr');
else if (label === 'STBY') setMode(1);
else if (label === 'ON') setMode(2);
else if (label === 'ALT') setMode(3);
else if (label === 'VFR') xp && xp.setDataref('xpdrCode', 1200);
else if (label === 'CODE') { setSquawk(''); setPage('xpdrcode'); }
else if (label === 'IDENT') xp && xp.command('xpdrIdent');
else if (label === 'BKSP') setSquawk((s) => s.slice(0, -1));
else if (page === 'xpdrcode' && /^[0-7]$/.test(label)) typeDigit(label);
}
};
// which softkey is "lit" right now
const isOn = (label) => {
if (variant === 'mfd') return (label === 'TOPO' && mapMode?.base === 'topo')
|| (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)
|| (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)
|| (page === 'inset' && label === 'TOPO' && insetMode?.base === 'topo')
|| (page === 'inset' && label === 'TERRAIN' && insetMode?.terrain)
|| (label === 'DCLTR' && insetMode?.dcltr > 0);
};
return (
<div className="bezel">
<div className="bezel-knobs left">
<Knob label="NAV" sub="VOL · PUSH ID" fire={fire} mode={knobMode}
outer={['nav_outer_up', 'nav_outer_down']} inner={['nav_inner_up', 'nav_inner_down']} push="nav12"
swap={() => xp && xp.command('nav1Swap')} />
<Knob label="HDG" sub="PUSH HDG SYNC" fire={fire} mode={knobMode}
outer={['hdg_up', 'hdg_down']} push="hdg_sync" />
{variant === 'mfd' && xp && <APController xp={xp} />}
<Knob label="ALT" sub="" big fire={fire} mode={knobMode}
outer={['alt_outer_up', 'alt_outer_down']} inner={['alt_inner_up', 'alt_inner_down']} />
</div>
<div className="bezel-core">
<div className="bezel-title">XPLANE 1000</div>
<div className="bezel-screen">
<div className="screen-content">{children}</div>
{page === 'xpdrcode' && (
<div className="squawk-entry">SQUAWK <b>{squawk.padEnd(4, '_')}</b></div>
)}
{/* softkey LABELS on the display (lowest line), like the real G1000 */}
<div className="sk-labels">
{keys.map((s, i) => (
<span key={i} className={`skl ${s ? '' : 'empty'} ${s === 'CAUTION' ? 'caution' : ''} ${isOn(s) ? 'on' : ''}`}>{
(s === 'DCLTR' && (mapMode?.dcltr || insetMode?.dcltr)) ? `DCLTR-${mapMode?.dcltr || insetMode?.dcltr}` : s
}</span>
))}
</div>
</div>
{/* physical bezel keys — blank, aligned under the on-screen labels */}
<div className="softkeys">
{keys.map((s, i) => (
<button key={i} disabled={!s} onClick={() => onSoftkey(i, s)}
className={`softkey ${s ? '' : 'empty'}`} aria-label={s || undefined} />
))}
</div>
</div>
<div className="bezel-knobs right">
<Knob label="COM" sub="VOL · PUSH SQ" fire={fire} mode={knobMode}
outer={['com_outer_up', 'com_outer_down']} inner={['com_inner_up', 'com_inner_down']} push="com12"
swap={() => xp && xp.command('com1Swap')} emerg />
<Knob label="CRS / BARO" sub="PUSH CRS CTR" fire={fire} mode={knobMode}
outer={['crs_up', 'crs_down']} inner={['baro_up', 'baro_down']} push="crs_sync" />
<Knob label="RANGE" sub="PUSH PAN" joy fire={fire} mode={knobMode}
outer={['range_up', 'range_down']} push="pan_push" pan />
<div className="bezel-grid">
<BtnG fire={fire} mode={knobMode} cmd="direct" onClick={onDirect}>D</BtnG><BtnG fire={fire} mode={knobMode} cmd="menu">MENU</BtnG>
<BtnG fire={fire} mode={knobMode} cmd="fpl" onClick={onFpl}>FPL</BtnG><BtnG fire={fire} mode={knobMode} cmd="proc" onClick={onProc}>PROC</BtnG>
<BtnG fire={fire} mode={knobMode} cmd="clr" onClick={onClr}>CLR</BtnG><BtnG fire={fire} mode={knobMode} cmd="ent">ENT</BtnG>
</div>
<Knob label="FMS" sub="PUSH CRSR" big fire={fire} mode={knobMode}
outer={['fms_outer_up', 'fms_outer_down']} inner={['fms_inner_up', 'fms_inner_down']} push="cursor"
onTurn={onFms} />
</div>
</div>
);
}
function BtnG({ fire, cmd, onClick, children }) {
return <button className="bezel-btn sm" onClick={() => { fire(cmd); onClick && onClick(); }}>{children}</button>;
}
// Autopilot mode controller (left bezel of the MFD). Buttons fire real X-Plane
// commands; active modes light up from autopilot_state / servos_on.
function APController({ xp }) {
const st = num(xp.values.apState);
const on = (bit) => (st & bit) !== 0;
const eng = num(xp.values.apEngaged) > 0;
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="NOSE UP" cmd="noseUp" />
<B label="NOSE DN" cmd="noseDown" />
</div>
);
}
// Concentric G1000 knob. The outer ring rotates via the side arrows ( ) and
// the mouse wheel; the inner ring via the top/bottom arrows (˄ ˅) and shift+wheel.
// Clicking the knob centre fires the push action (PUSH …). The RANGE knob also
// pans with a directional cross.
function Knob({ label, sub, outer, inner, push, big, joy, pan, fire, mode = 'arrows', swap, emerg, onTurn }) {
// turn the outer ring: fire the sim command AND notify (e.g. cycle MFD page)
const outerStep = (dir) => { if (!outer) return; fire(dir > 0 ? outer[0] : outer[1]); if (onTurn) onTurn(dir); };
const onWheel = (e) => {
if (!outer) return;
e.preventDefault();
if (e.shiftKey && inner) fire(e.deltaY < 0 ? inner[0] : inner[1]);
else outerStep(e.deltaY < 0 ? 1 : -1);
};
const zoneClick = (e) => {
const r = e.currentTarget.getBoundingClientRect();
const dx = e.clientX - (r.left + r.width / 2);
const dy = e.clientY - (r.top + r.height / 2);
const rel = Math.hypot(dx, dy) / (r.width / 2);
if (rel < 0.42 && push) { fire(push); return; } // centre → PUSH
if (Math.abs(dy) >= Math.abs(dx)) outerStep(dy < 0 ? 1 : -1);
else if (inner) fire(dx > 0 ? inner[0] : inner[1]);
else outerStep(dx > 0 ? 1 : -1);
};
const zones = mode === 'zones';
return (
<div className={`knob-wrap ${big ? 'big' : ''}`}>
{swap && <button className="knob-swap" onClick={swap} title="Aktiv ↔ Standby"></button>}
{emerg && <span className="knob-emerg">EMERG</span>}
<span className="knob-lbl">{label}</span>
<div className={`knob-cluster ${zones ? 'zones' : ''}`}>
{/* arrows mode (touch-friendly): visible ˄‹›˅ buttons. zones mode: click
the knob face itself (top/bottom = outer, left/right = inner). */}
{!zones && inner && <button className="knob-arrow top" onClick={() => fire(inner[0])}>˄</button>}
{!zones && outer && <button className="knob-arrow left" onClick={() => outerStep(-1)}></button>}
<button
className={`knob outer ${joy ? 'joy' : ''}`}
onWheel={onWheel}
onClick={zones ? zoneClick : (() => push && fire(push))}
title={zones ? `${outer ? 'oben/unten' : ''}${inner ? ' · links/rechts (fein)' : ''}${push ? ' · Mitte: PUSH' : ''}` : (push ? 'PUSH' : '')}
>
<span className="knob inner" />
{joy && (<>
<span className="rng-ring" />
<span className="rng-arc l"></span>
<span className="rng-arc r"></span>
<span className="rng-sign m"></span>
<span className="rng-sign p">+</span>
</>)}
</button>
{!zones && outer && <button className="knob-arrow right" onClick={() => outerStep(1)}></button>}
{!zones && inner && <button className="knob-arrow bottom" onClick={() => fire(inner[1])}>˅</button>}
</div>
{pan && (
<div className="pan-pad">
<button onClick={() => fire('pan_up')}></button>
<button onClick={() => fire('pan_left')}></button>
<button onClick={() => fire('pan_right')}></button>
<button onClick={() => fire('pan_down')}></button>
</div>
)}
{sub && <span className="knob-sub">{sub}</span>}
</div>
);
}