Files
xplane-cockpit/web/src/App.jsx
T

280 lines
16 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';
import KAP140 from './components/KAP140.jsx';
import CitPFD from './components/citation/CitPFD.jsx';
import CitMFD from './components/citation/CitMFD.jsx';
import CitDuo from './components/citation/CitDuo.jsx';
import CitEICAS from './components/citation/CitEICAS.jsx';
import CitAP from './components/citation/CitAP.jsx';
import CitRMU from './components/citation/CitRMU.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',
eicas: 'M5 4v14M9 4v14M5 11h4M13 7h5M13 11h5M13 15h5',
rmu: 'M4 5h14v12H4zM7 8h8M7 11h8M7 14h4',
duo: 'M3 5h7v12H3zM12 5h7v12h-7z',
};
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] || ICONS.mfd} />
{name === 'map' && <circle cx="11" cy="8" r="2" />}
</svg>
);
}
// Three selectable cockpit profiles. Each maps the app to a different aircraft's
// avionics suite, sharing the same bridge/datarefs underneath.
const PROFILES = {
g1000: {
label: 'Garmin G1000', short: 'G1000',
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' },
],
},
citation: {
label: 'Cessna Citation X', short: 'CITATION X',
tabs: [
{ id: 'duo', label: 'PFD+MFD' }, { id: 'pfd', label: 'PFD' }, { id: 'mfd', label: 'MFD' },
{ id: 'eicas', label: 'EICAS' }, { id: 'fms', label: 'CDU/FMS' }, { id: 'ap', label: 'Autopilot' },
{ id: 'rmu', label: 'Radios' }, { id: 'map', label: 'Map' },
],
},
ga: {
label: 'GA Steam (Bendix/King)', short: 'GA PANEL',
tabs: [
{ id: 'vfr', label: 'Panel' }, { id: 'ap', label: 'KAP 140' },
{ id: 'map', label: 'Map' }, { id: 'audio', label: 'Audio' },
],
},
};
export default function App() {
const xp = useXplane();
// Active cockpit profile — persisted; switches the whole avionics suite.
const [profile, setProfile] = useState(() => localStorage.getItem('cockpitProfile') || 'g1000');
// Citation Nav Source Selector bearing-pointer sources (p24): pointer 1 = cyan
// circle, pointer 2 = white diamond. Each OFF/VORn/ADFn/FMSn. Shared PFD↔RMU.
const [navSrc, setNavSrc] = useState(() => {
try { return JSON.parse(localStorage.getItem('citNavSrc')) || { brg1: 'VOR1', brg2: 'VOR2' }; }
catch { return { brg1: 'VOR1', brg2: 'VOR2' }; }
});
useEffect(() => { localStorage.setItem('citNavSrc', JSON.stringify(navSrc)); }, [navSrc]);
const [profMenu, setProfMenu] = useState(false);
const PROF = PROFILES[profile] || PROFILES.g1000;
const TABS = PROF.tabs;
const pickProfile = (p) => {
localStorage.setItem('cockpitProfile', p); setProfile(p); setProfMenu(false);
const first = PROFILES[p].tabs[0].id; setTab(first); history.replaceState(null, '', `#${first}`);
};
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]);
// Keep the active tab valid for the current profile (e.g. after a hash deep-link
// into a tab the profile doesn't have).
useEffect(() => {
if (!TABS.some((t) => t.id === tab)) { const f = TABS[0].id; setTab(f); history.replaceState(null, '', `#${f}`); }
}, [profile]); // eslint-disable-line react-hooks/exhaustive-deps
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={() => setProfMenu((v) => !v)} title="Cockpit-Profil wählen">
<span className="brand">{PROF.short}</span>
<span className="sb-chev">{profMenu ? '▴' : '▾'}</span>
</button>
{profMenu && (
<div className="prof-menu">
{Object.entries(PROFILES).map(([id, p]) => (
<button key={id} className={`prof-i ${id === profile ? 'on' : ''}`} onClick={() => pickProfile(id)}>
{p.label}
</button>
))}
<button className="prof-collapse" onClick={() => { setProfMenu(false); toggleNav(); }}>
{navWide ? '◂ Menü einklappen' : '▸ Menü ausklappen'}
</button>
</div>
)}
<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">
{/* ---- Garmin G1000 suite ---- */}
{profile === 'g1000' && 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>
)}
{profile === 'g1000' && 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>
)}
{/* ---- Cessna Citation X suite (Honeywell Primus 2000) ---- */}
{profile === 'citation' && tab === 'duo' && <CitDuo xp={xp} navSrc={navSrc} />}
{profile === 'citation' && tab === 'pfd' && <CitPFD xp={xp} navSrc={navSrc} />}
{profile === 'citation' && tab === 'mfd' && <CitMFD xp={xp} />}
{profile === 'citation' && tab === 'eicas' && <CitEICAS xp={xp} />}
{profile === 'citation' && tab === 'ap' && <CitAP xp={xp} />}
{profile === 'citation' && tab === 'rmu' && <CitRMU xp={xp} navSrc={navSrc} onNavSrc={setNavSrc} />}
{/* ---- shared tabs ---- */}
{tab === 'map' && <MapView values={xp.values} flightPlan={xp.flightPlan} fp={xp.fp} />}
{tab === 'fms' && <CDU xp={xp} vnav={vnavCfg} onVnav={setVnavCfg} />}
{tab === 'vfr' && <VFR xp={xp} />}
{tab === 'audio' && <AudioPanel xp={xp} />}
{tab === 'ap' && profile === 'g1000' && <AutopilotPanel xp={xp} />}
{tab === 'ap' && profile === 'ga' && <KAP140 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>
);
}