28ab984185
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>
207 lines
12 KiB
React
207 lines
12 KiB
React
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>
|
||
);
|
||
}
|