Files
xplane-cockpit/web/src/components/Bezel.jsx
T
karim 033a9d406a G1000: manual-accurate radios, baro units, declutter, minimums, OBS, audio panel
Aligned to the official X-Plane 1000 manual:
- NAV radio: active RIGHT / standby LEFT (boxed) per S.12 (COM already correct)
- ALT UNIT softkey (IN / HPA) in the PFD submenu, baro readout converts (S.20)
- DCLTR cycles 3 levels (land / +NDB / flight-plan only) with DCLTR-n label (S.56)
- TOPO and TERRAIN are now independent toggles (relief vs awareness overlay) (S.57)
- Barometric MINIMUMS: BARO MIN bug + readout on the altimeter, amber "MINIMUMS"
  annunciation at/below the decision altitude; set via TMR/REF (lifted to App)
- OBS mode: HSI course follows the CRS knob (magenta "OBS"), sequencing suspended
- New Audio Panel tab (COM mic/receive, MKR/DME/ADF, intercom, Display Backup) (S.91)

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-02 05:55:56 +02:00

276 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', '', '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 === '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);
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>
);
}