Files
xplane-cockpit/web/src/App.jsx
T
karim 28ab984185 Wire MENU page-menu + HRZN HDG — last dead display keys now functional
MENU (bezel hard key) now opens a G1000-style PAGE MENU (Invert / Store / Delete
flight plan) instead of only mirroring the sim command. HRZN HDG draws heading
reference marks (N/E/S/W + ticks) along the attitude horizon, toggled from the
PFD submenu. With TRAFFIC/NEXRAD/PROFILE/PATHWAY/APTSIGNS already wired, every
softkey now does something; only ENT remains a pure sim-command mirror.

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

207 lines
12 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, useEffect } from 'react';
import { useXplane } from './api/useXplane.js';
import PFD from './components/PFD.jsx';
import AutopilotPanel from './components/AutopilotPanel.jsx';
import MFD from './components/MFD.jsx';
import MapView from './components/MapView.jsx';
import CDU from './components/CDU.jsx';
import VFR from './components/VFR.jsx';
import Bezel from './components/Bezel.jsx';
import DirectTo from './components/DirectTo.jsx';
import Proc from './components/Proc.jsx';
import FplPage from './components/FplPage.jsx';
import AudioPanel from './components/AudioPanel.jsx';
// Compact line icons for the nav rail (stroke = currentColor).
const ICONS = {
pfd: 'M11 3a8 8 0 100 16 8 8 0 000-16zM3.5 11h15M7 8l1.5 1M15 8l-1.5 1',
mfd: 'M3 6l5-2 6 2 5-2v12l-5 2-6-2-5 2zM8 4v12M14 6v12',
map: 'M11 2c-3.3 0-6 2.6-6 5.9 0 4.4 6 11.1 6 11.1s6-6.7 6-11.1C17 4.6 14.3 2 11 2z',
fms: 'M4 6h14M4 11h14M4 16h9',
ap: 'M11 4a7 7 0 100 14 7 7 0 000-14zM11 4v3M11 15v3M4 11h3M15 11h3',
vfr: 'M11 4a7 7 0 100 14 7 7 0 000-14zM11 11l4.5-3',
audio: 'M11 4a6 6 0 00-6 6v5M17 15v-5a6 6 0 00-6-6M4 14h2.5v4.5H4zM15.5 14H18v4.5h-2.5z',
};
function Icon({ name }) {
return (
<svg className="snav-ic" viewBox="0 0 22 22" width="22" height="22" fill="none"
stroke="currentColor" strokeWidth="1.6" strokeLinecap="round" strokeLinejoin="round">
<path d={ICONS[name]} />
{name === 'map' && <circle cx="11" cy="8" r="2" />}
</svg>
);
}
const TABS = [
{ id: 'pfd', label: 'PFD' },
{ id: 'mfd', label: 'MFD' },
{ id: 'map', label: 'Map' },
{ id: 'fms', label: 'FMS' },
{ id: 'vfr', label: 'VFR' },
{ id: 'ap', label: 'Autopilot' },
{ id: 'audio', label: 'Audio' },
];
export default function App() {
const xp = useXplane();
const [tab, setTab] = useState(() => location.hash.replace('#', '') || 'pfd');
// Collapsible nav rail: narrow (icons) ↔ wide (icons + labels), remembered.
const [navWide, setNavWide] = useState(() => localStorage.getItem('navWide') === '1');
const go = (id) => { setTab(id); history.replaceState(null, '', `#${id}`); };
const toggleNav = () => setNavWide((w) => { localStorage.setItem('navWide', w ? '0' : '1'); return !w; });
// Knob interaction: 'arrows' (visible ˄‹›˅, touch-friendly) or 'zones' (click
// the knob face). Settable in the settings panel, remembered.
const [knobMode, setKnobMode] = useState(() => localStorage.getItem('knobMode') || 'arrows');
const [settings, setSettings] = useState(false);
const setKnob = (m) => { localStorage.setItem('knobMode', m); setKnobMode(m); };
// Synthetic-terrain (3D) vs. classic blue/brown attitude — toggled by the
// PFD → SYN TERR softkey, exactly like the real XPLANE 1000.
const [svt3d, setSvt3d] = useState(false);
// The PFD INSET map (bottom-left) is off by default and toggled by its softkey.
const [inset, setInset] = useState(false);
// INSET map options (base layer + declutter), set from the INSET submenu.
const [insetMode, setInsetMode] = useState({ base: 'topo', dcltr: 0 });
// Like the real G1000, only ONE window is open at a time. A single string
// holds the open one (nrst / tmr / dme / alerts / fpl / dto / proc); toggling
// the same softkey closes it, opening another replaces it.
const [win, setWin] = useState(null);
const toggleWin = (id) => setWin((w) => (w === id ? null : id));
const nrst = win === 'nrst', tmr = win === 'tmr', dme = win === 'dme', alerts = win === 'alerts';
const fpl = win === 'fpl', dto = win === 'dto', proc = win === 'proc', menu = win === 'menu';
// MFD map mode (base layer + overlays), switched via the Map-Opt softkeys.
const [mapMode, setMapMode] = useState({ base: 'topo' });
// VNAV profile control (FPL VNAV keys + Direct-To descent): enabled gates the
// 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, hrznHdg: false });
// 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.
const [minimums, setMinimums] = useState({ on: false, ft: 500 });
// OBS (omni-bearing select) mode — suspends GPS sequencing, course set by CRS knob.
const [obs, setObs] = useState(false);
// MFD page group (MAP / FPL / NRST) — selected by the FMS knob, like the real G1000.
const MFD_PAGES = ['map', 'fpl', 'nrst'];
const [mfdPage, setMfdPage] = useState('map');
const cycleMfd = (dir = 1) => setMfdPage((p) => MFD_PAGES[(MFD_PAGES.indexOf(p) + dir + MFD_PAGES.length) % MFD_PAGES.length]);
// G1000 UI-state sync (Sim → App): follow the in-sim G1000 when the FlyWithLua
// companion publishes its state. No-ops until then, so local control still works.
const uiInset = xp.values.uiInset, uiPage = xp.values.uiMfdPage;
useEffect(() => { if (uiInset === 0 || uiInset === 1) setInset(!!uiInset); }, [uiInset]);
useEffect(() => { if (typeof uiPage === 'number' && MFD_PAGES[uiPage]) setMfdPage(MFD_PAGES[uiPage]); }, [uiPage]);
const connKind = xp.xpConnected ? 'ok' : xp.connected ? 'warn' : 'bad';
const connText = xp.xpConnected ? 'X-PLANE' : xp.connected ? 'NO SIM' : 'OFFLINE';
// G1000 side-window dialogs — rendered inside the bezel display so they sit in
// the display's lower-right (like the real unit), not over the whole app.
const dialogs = (
<>
{dto && <DirectTo xp={xp} onClose={() => setWin(null)} vnav={vnavCfg} onVnav={setVnavCfg} />}
{proc && <Proc xp={xp} onClose={() => setWin(null)} />}
{menu && (() => {
const wps = xp.flightPlan?.waypoints || [];
const act = (fn) => { fn(); setWin(null); };
const item = (label, on, dis) => <button className="proc-menu-i" disabled={dis} onClick={() => act(on)}>{label}</button>;
return (
<div className="gwin-backdrop" onClick={() => setWin(null)}>
<div className="dlg proc menu" onClick={(e) => e.stopPropagation()}>
<div className="dlg-head">PAGE MENU</div>
<div className="proc-menu">
{item('INVERT FLIGHT PLAN', () => xp.fp.set({ name: 'ACTIVE', waypoints: wps.slice().reverse(), activeLeg: 1 }), wps.length < 2)}
{item('STORE FLIGHT PLAN', () => xp.fp.export('WEBFPL'), wps.length < 2)}
{item('DELETE FLIGHT PLAN', () => xp.fp.clear(), wps.length < 1)}
{item('CANCEL', () => {})}
</div>
</div>
</div>
);
})()}
{fpl && (
<div className="gwin-backdrop" onClick={() => setWin(null)}>
<div onClick={(e) => e.stopPropagation()}><FplPage xp={xp} onClose={() => setWin(null)} vnav={vnavCfg} onVnav={setVnavCfg} /></div>
</div>
)}
</>
);
return (
<div className={`app ${navWide ? 'nav-wide' : 'nav-narrow'}`}>
<aside className="sidebar">
<button className="sb-top" onClick={toggleNav} title="Menü ein-/ausklappen">
<span className="brand">G<span>1000</span></span>
<span className="sb-chev">{navWide ? '◂' : '▸'}</span>
</button>
<nav className="snav">
{TABS.map((t) => (
<button key={t.id} className={tab === t.id ? 'snav-i active' : 'snav-i'}
onClick={() => go(t.id)} title={t.label}>
<Icon name={t.id} />
<span className="snav-lbl">{t.label}</span>
</button>
))}
</nav>
<button className="snav-i sb-gear" onClick={() => setSettings(true)} title="Einstellungen">
<svg className="snav-ic" viewBox="0 0 22 22" width="22" height="22" fill="none" stroke="currentColor" strokeWidth="1.6" strokeLinecap="round" strokeLinejoin="round">
<circle cx="11" cy="11" r="3.2" />
<path d="M11 2.5v2M11 17.5v2M2.5 11h2M17.5 11h2M5 5l1.4 1.4M15.6 15.6L17 17M17 5l-1.4 1.4M6.4 15.6L5 17" />
</svg>
<span className="snav-lbl">Einstellungen</span>
</button>
<div className={`sb-conn ${connKind}`} title={connText}>
<span className="dot" />
<span className="snav-lbl">{connText}</span>
</div>
</aside>
<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')}
alerts={alerts} onToggleAlerts={() => toggleWin('alerts')} onProc={() => toggleWin('proc')} onFpl={() => toggleWin('fpl')} onMenu={() => toggleWin('menu')} onClr={() => setWin(null)}
altHpa={baroHpa} onAltUnit={setBaroHpa} obs={obs} onObs={() => setObs((v) => !v)}>
<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} svtOpts={svtOpts} />
{dialogs}
</Bezel>
)}
{tab === 'mfd' && (
<Bezel variant="mfd" xp={xp} knobMode={knobMode} mapMode={mapMode} onMapMode={setMapMode} onDirect={() => toggleWin('dto')} onProc={() => toggleWin('proc')} onFms={cycleMfd} onFpl={() => setMfdPage('fpl')} onMenu={() => toggleWin('menu')} onClr={() => setWin(null)}>
<MFD values={xp.values} flightPlan={xp.flightPlan} fp={xp.fp} mapMode={mapMode} page={mfdPage} onCycle={cycleMfd} xp={xp} vnav={vnavCfg} onVnav={setVnavCfg} />
{dialogs}
</Bezel>
)}
{tab === 'map' && <MapView values={xp.values} flightPlan={xp.flightPlan} fp={xp.fp} />}
{tab === 'fms' && <CDU xp={xp} />}
{tab === 'vfr' && <VFR xp={xp} />}
{tab === 'ap' && <AutopilotPanel xp={xp} />}
{tab === 'audio' && <AudioPanel xp={xp} />}
</main>
{settings && (
<div className="dlg-backdrop" onClick={() => setSettings(false)}>
<div className="dlg" onClick={(e) => e.stopPropagation()} style={{ minWidth: 360 }}>
<div className="dlg-head">EINSTELLUNGEN</div>
<div style={{ padding: 14 }}>
<div className="set-lbl">Knopf-Bedienung</div>
<div className="set-opt">
<button className={`fbtn ${knobMode === 'arrows' ? 'add' : ''}`} onClick={() => setKnob('arrows')}>Pfeiltasten ˄˅</button>
<button className={`fbtn ${knobMode === 'zones' ? 'add' : ''}`} onClick={() => setKnob('zones')}>Klickzonen am Knopf</button>
</div>
<div className="set-hint">Pfeiltasten sind touch-freundlich. Klickzonen: oben/unten = grob, links/rechts = fein, Mitte = PUSH.</div>
</div>
<div className="dlg-actions"><button className="fbtn" onClick={() => setSettings(false)}>Schließen</button></div>
</div>
</div>
)}
</div>
);
}