G1000: two-way sim sync, more PFD/MFD fidelity, authentic dialogs

Sync (FlyWithLua companions in plugins/ + server/fmssync.js):
- FMS flight-plan two-way sync (App <-> in-sim FMS) via fms-sync.lua
- G1000 UI-state publish (page/range/inset) via ui-sync.lua + CDI source,
  baro, map-range follow
- Terrain awareness: elevation grid probe (terrain-probe.lua) -> red/yellow
  MFD overlay vs aircraft altitude

PFD:
- AFCS mode annunciation bar from autopilot _status datarefs
- CDI source GPS/VLOC colouring, BRG1/BRG2 pointers + DME windows, marker beacons
- magenta speed/altitude trend vectors, selected-altitude alerting
- time-based (frame-rate-independent) smoothing for attitude/heading/tapes

MFD:
- nav data bar (DTK/ETE/active leg), airways overlay from earth_awy.dat,
  compass rose anchored to the ownship

Dialogs (NEAREST/FLIGHTPLAN/DIRECT-TO/PROCEDURES):
- flat, square, embedded G1000 look (no shadow/rounded/transparency)
- compact lower-right placement, no close X (softkey toggles), single window
- NEAREST 2-line entries (ILS/VFR, COM freq, runway length), PROC action menu

Service worker: network-first HTML so reloads pick up new builds (cache v2).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
2026-06-02 02:17:06 +02:00
parent 354ea5d44b
commit 38b048ad41
23 changed files with 1707 additions and 213 deletions
+14 -2
View File
@@ -1,7 +1,7 @@
// Minimal service worker: caches the app shell so the cockpit launches fast and
// survives brief network blips. Live data (the bridge WebSocket, /api, and map
// tiles) is never cached — only same-origin GET app assets.
const CACHE = 'g1000-shell-v1';
const CACHE = 'g1000-shell-v2';
self.addEventListener('install', () => self.skipWaiting());
@@ -18,7 +18,19 @@ self.addEventListener('fetch', (e) => {
if (e.request.method !== 'GET' || url.origin !== location.origin) return;
if (url.pathname.startsWith('/api') || url.pathname === '/ws') return;
// Stale-while-revalidate: serve cache fast, refresh in the background.
// The HTML entry is NETWORK-FIRST: a reload always gets the latest build (and
// thus the latest hashed assets). Falls back to cache only when offline.
const isDoc = e.request.mode === 'navigate' || url.pathname === '/' || url.pathname.endsWith('.html');
if (isDoc) {
e.respondWith(
fetch(e.request)
.then((res) => { caches.open(CACHE).then((c) => c.put(e.request, res.clone())); return res; })
.catch(() => caches.match(e.request).then((c) => c || caches.match('/')))
);
return;
}
// Hashed assets are immutable → stale-while-revalidate (fast + self-healing).
e.respondWith(
caches.open(CACHE).then(async (cache) => {
const cached = await cache.match(e.request);
+42 -17
View File
@@ -1,4 +1,4 @@
import React, { useState } from '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';
@@ -9,6 +9,7 @@ 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';
// Compact line icons for the nav rail (stroke = currentColor).
const ICONS = {
@@ -57,20 +58,42 @@ export default function App() {
const [inset, setInset] = useState(false);
// INSET map options (base layer + declutter), set from the INSET submenu.
const [insetMode, setInsetMode] = useState({ base: 'topo', dcltr: 0 });
// The NRST (nearest airports/navaids) window, toggled by the PFD NRST softkey.
const [nrst, setNrst] = useState(false);
// The TMR/REF (timer / references) window, toggled by the PFD TMR/REF softkey.
const [tmr, setTmr] = useState(false);
// 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';
// MFD map mode (base layer), switched via the Map-Opt softkeys.
const [mapMode, setMapMode] = useState({ base: 'topo' });
// Direct-To (D→) dialog — opened from the bezel on either GDU.
const [dto, setDto] = useState(false);
// PROC (procedures: SID/STAR/approach) dialog — opened from the bezel.
const [proc, setProc] = 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)} />}
{proc && <Proc xp={xp} onClose={() => setWin(null)} />}
{fpl && (
<div className="gwin-backdrop" onClick={() => setWin(null)}>
<div onClick={(e) => e.stopPropagation()}><FplPage xp={xp} onClose={() => setWin(null)} /></div>
</div>
)}
</>
);
return (
<div className={`app ${navWide ? 'nav-wide' : 'nav-narrow'}`}>
<aside className="sidebar">
@@ -104,15 +127,19 @@ export default function App() {
{tab === 'pfd' && (
<Bezel variant="pfd" xp={xp} knobMode={knobMode} svt3d={svt3d} onToggleSvt={() => setSvt3d((v) => !v)}
inset={inset} onSetInset={setInset} insetMode={insetMode} onInsetMode={setInsetMode}
nrst={nrst} onToggleNrst={() => setNrst((v) => !v)} onDirect={() => setDto(true)}
tmr={tmr} onToggleTmr={() => setTmr((v) => !v)} onProc={() => setProc(true)}>
<PFD values={xp.values} svt={svt3d} inset={inset} insetMode={insetMode} nrst={nrst} onCloseNrst={() => setNrst(false)}
tmr={tmr} onCloseTmr={() => setTmr(false)} flightPlan={xp.flightPlan} fp={xp.fp} />
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')}>
<PFD values={xp.values} command={xp.command} 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)} flightPlan={xp.flightPlan} fp={xp.fp} />
{dialogs}
</Bezel>
)}
{tab === 'mfd' && (
<Bezel variant="mfd" xp={xp} knobMode={knobMode} mapMode={mapMode} onMapMode={setMapMode} onDirect={() => setDto(true)} onProc={() => setProc(true)}>
<MFD values={xp.values} flightPlan={xp.flightPlan} fp={xp.fp} mapMode={mapMode} />
<Bezel variant="mfd" xp={xp} knobMode={knobMode} mapMode={mapMode} onMapMode={setMapMode} onDirect={() => toggleWin('dto')} onProc={() => toggleWin('proc')} onFms={cycleMfd} onFpl={() => setMfdPage('fpl')}>
<MFD values={xp.values} flightPlan={xp.flightPlan} fp={xp.fp} mapMode={mapMode} page={mfdPage} onCycle={cycleMfd} xp={xp} />
{dialogs}
</Bezel>
)}
{tab === 'map' && <MapView values={xp.values} flightPlan={xp.flightPlan} fp={xp.fp} />}
@@ -120,8 +147,6 @@ export default function App() {
{tab === 'vfr' && <VFR xp={xp} />}
{tab === 'ap' && <AutopilotPanel xp={xp} />}
</main>
{dto && <DirectTo xp={xp} onClose={() => setDto(false)} />}
{proc && <Proc xp={xp} onClose={() => setProc(false)} />}
{settings && (
<div className="dlg-backdrop" onClick={() => setSettings(false)}>
<div className="dlg" onClick={(e) => e.stopPropagation()} style={{ minWidth: 360 }}>
+28 -1
View File
@@ -6,6 +6,7 @@ import { useEffect, useRef, useState, useCallback } from 'react';
export function useXplane() {
const [values, setValues] = useState({});
const [flightPlan, setFlightPlan] = useState({ name: 'ACTIVE', waypoints: [] });
const [terrain, setTerrain] = useState(null); // elevation grid for terrain awareness
const [exportMsg, setExportMsg] = useState(null);
const [connected, setConnected] = useState(false); // socket to bridge
const [xpConnected, setXpConnected] = useState(false); // bridge <-> X-Plane
@@ -37,6 +38,7 @@ export function useXplane() {
}
else if (msg.type === 'status') setXpConnected(!!msg.xpConnected);
else if (msg.type === 'flightplan') setFlightPlan(msg.data);
else if (msg.type === 'terrain') setTerrain(msg.data);
else if (msg.type === 'fp_export_result') setExportMsg(msg);
};
ws.onclose = () => {
@@ -68,9 +70,16 @@ export function useXplane() {
clear: () => send({ type: 'fp_clear' }),
set: (plan) => send({ type: 'fp_set', plan }),
export: (name) => send({ type: 'fp_export', name }),
load: (name) => send({ type: 'fp_load', name }),
};
return { values, flightPlan, exportMsg, connected, xpConnected, command, setDataref, fp };
return { values, flightPlan, terrain, exportMsg, connected, xpConnected, command, setDataref, fp };
}
// List saved .fms flight plans (X-Plane's Output/FMS plans) via the bridge.
export async function fmsList() {
try { const r = await fetch('/api/fms/list'); return r.ok ? r.json() : []; }
catch { return []; }
}
// Search X-Plane's nav database (waypoints/VOR/NDB/airports) via the bridge.
@@ -86,3 +95,21 @@ export async function navSearch(q) {
// Convenience: read a numeric value with a fallback.
export const num = (v, d = 0) => (typeof v === 'number' && isFinite(v) ? v : d);
const v0 = (x) => (Array.isArray(x) ? num(x[0]) : num(x));
// System alerts/annunciations derived from live datarefs — drives the PFD
// CAUTION softkey + the ALERTS window. Each: { t: text, warn: bool (red vs amber) }.
export function systemAlerts(V = {}) {
const out = [];
const rpm = v0(V.engRpm);
const running = rpm > 400;
const oilP = v0(V.oilPress);
const oilT = v0(V.oilTemp); const oilF = oilT > 150 ? oilT : oilT * 9 / 5 + 32;
const volts = v0(V.volts);
const fuelGal = (Array.isArray(V.fuelQty) ? V.fuelQty.reduce((a, b) => a + num(b), 0) : num(V.fuelQty)) / 2.72;
if (running && oilP < 20) out.push({ t: 'OIL PRESSURE', warn: true });
if (oilF > 245) out.push({ t: 'OIL TEMP HIGH', warn: true });
if (Array.isArray(V.fuelQty) && fuelGal < 5) out.push({ t: 'FUEL LOW TOTAL', warn: fuelGal < 2.5 });
if (volts > 1 && volts < 24.5) out.push({ t: 'LOW VOLTS', warn: false });
return out;
}
+57 -29
View File
@@ -1,5 +1,5 @@
import React, { useState } from 'react';
import { num } from '../api/useXplane.js';
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,
@@ -26,25 +26,30 @@ const PFD_MENU = {
// 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: ['SYSTEM', 'MAP', '', '', '', '', '', '', '', 'DCLTR', '', ''],
root: ['ENGINE', 'MAP', '', '', '', '', '', '', '', 'DCLTR', '', ''],
mapopt: ['TRAFFIC', 'PROFILE', 'TOPO', 'TERRAIN', 'AIRWAYS', '', 'NEXRAD', 'OSM', '', '', 'BACK', ''],
system: ['DEC FUEL', 'INC FUEL', 'RST FUEL', '', '', '', '', '', '', '', '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, onDirect, onProc, mapMode, onMapMode, knobMode = 'arrows', children }) {
export default function Bezel({ variant = 'pfd', xp, svt3d, onToggleSvt, inset, onSetInset, insetMode, onInsetMode, nrst, onToggleNrst, tmr, onToggleTmr, dme, onToggleDme, alerts, onToggleAlerts, onDirect, onProc, onFpl, onFms, mapMode, onMapMode, 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 [obs, setObs] = useState(false); // OBS (suspend) mode
const menu = variant === 'mfd' ? MFD_MENU : PFD_MENU;
const keys = menu[page] || menu.root;
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);
@@ -59,12 +64,13 @@ export default function Bezel({ variant = 'pfd', xp, svt3d, onToggleSvt, inset,
fire(`softkey${i + 1}`); // mirror to the in-sim G1000
if (variant === 'mfd') {
if (label === 'MAP') setPage('mapopt');
else if (label === 'SYSTEM') setPage('system');
else if (label === 'ENGINE') setPage('engine');
else if (label === 'BACK') setPage('root');
else if (label === 'TOPO') setBase('topo');
else if (label === 'TERRAIN') setBase('terrain');
else if (label === 'OSM') setBase('osm');
else if (label === 'DCLTR') onMapMode && onMapMode((m) => ({ ...m, dcltr: m.dcltr ? 0 : 1 }));
else if (label === 'AIRWAYS') onMapMode && onMapMode((m) => ({ ...m, airways: !m.airways }));
} else {
if (label === 'PFD') setPage('pfd');
else if (label === 'BACK') setPage(page === 'xpdrcode' ? 'xpdr' : 'root');
@@ -79,6 +85,9 @@ export default function Bezel({ variant = 'pfd', xp, svt3d, onToggleSvt, inset,
else if (label === 'TERRAIN') onInsetMode && onInsetMode((m) => ({ ...m, base: 'terrain' }));
else if (label === 'NRST') onToggleNrst && onToggleNrst();
else if (label === 'TMR/REF') onToggleTmr && onToggleTmr();
else if (label === 'DME') onToggleDme && onToggleDme();
else if (label === 'OBS') setObs((v) => !v); // 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);
@@ -94,8 +103,9 @@ export default function Bezel({ variant = 'pfd', xp, svt3d, onToggleSvt, inset,
const isOn = (label) => {
if (variant === 'mfd') return (label === 'TOPO' && mapMode?.base === 'topo')
|| (label === 'TERRAIN' && mapMode?.base === 'terrain') || (label === 'OSM' && mapMode?.base === 'osm')
|| (label === 'DCLTR' && mapMode?.dcltr > 0);
|| (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)
|| (page === 'inset' && label === 'TOPO' && insetMode?.base === 'topo')
|| (page === 'inset' && label === 'TERRAIN' && insetMode?.base === 'terrain')
@@ -106,7 +116,8 @@ export default function Bezel({ variant = 'pfd', xp, svt3d, onToggleSvt, inset,
<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" />
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} />}
@@ -116,36 +127,43 @@ export default function Bezel({ variant = 'pfd', xp, svt3d, onToggleSvt, inset,
<div className="bezel-core">
<div className="bezel-title">XPLANE 1000</div>
<div className="bezel-screen">{children}</div>
{page === 'xpdrcode' && (
<div className="squawk-entry">SQUAWK <b>{squawk.padEnd(4, '_')}</b></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}</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'} ${s === 'CAUTION' ? 'caution' : ''} ${isOn(s) ? 'on' : ''}`}
>{s}</button>
<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" />
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">FPL</BtnG><BtnG fire={fire} mode={knobMode} cmd="proc" onClick={onProc}>PROC</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">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" />
outer={['fms_outer_up', 'fms_outer_down']} inner={['fms_inner_up', 'fms_inner_down']} push="cursor"
onTurn={onFms} />
</div>
</div>
);
@@ -186,12 +204,14 @@ function APController({ xp }) {
// 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' }) {
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();
const set = (e.shiftKey && inner) ? inner : outer;
fire(e.deltaY < 0 ? set[0] : set[1]);
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();
@@ -199,19 +219,21 @@ function Knob({ label, sub, outer, inner, push, big, joy, pan, fire, mode = 'arr
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)) { if (outer) fire(dy < 0 ? outer[0] : outer[1]); }
if (Math.abs(dy) >= Math.abs(dx)) outerStep(dy < 0 ? 1 : -1);
else if (inner) fire(dx > 0 ? inner[0] : inner[1]);
else if (outer) fire(dx > 0 ? outer[0] : outer[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={() => fire(outer[1])}></button>}
{!zones && outer && <button className="knob-arrow left" onClick={() => outerStep(-1)}></button>}
<button
className={`knob outer ${joy ? 'joy' : ''}`}
onWheel={onWheel}
@@ -219,9 +241,15 @@ function Knob({ label, sub, outer, inner, push, big, joy, pan, fire, mode = 'arr
title={zones ? `${outer ? 'oben/unten' : ''}${inner ? ' · links/rechts (fein)' : ''}${push ? ' · Mitte: PUSH' : ''}` : (push ? 'PUSH' : '')}
>
<span className="knob inner" />
{joy && <div className="joy-cross"></div>}
{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={() => fire(outer[0])}></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 && (
+12 -8
View File
@@ -49,11 +49,10 @@ export default function DirectTo({ xp, onClose }) {
};
return (
<div className="dlg-backdrop" onClick={onClose}>
<div className="gwin-backdrop" onClick={onClose}>
<div className="dlg dto" onClick={(e) => e.stopPropagation()}>
<div className="dlg-head"><span className="dto-arrow">D</span> DIRECT TO</div>
<div className="dlg-head">DIRECT TO</div>
<div className="dto-body">
<label className="dto-lbl">WAYPOINT</label>
<input
ref={inputRef}
className="dto-input"
@@ -74,11 +73,16 @@ export default function DirectTo({ xp, onClose }) {
</div>
)}
{sel && (
<div className="dto-sel">
<span className="dto-id">{sel.id}</span>
<span className="dto-type">{sel.type}</span>
{preview && <span className="dto-vec">{String(Math.round(preview.brg)).padStart(3, '0')}° · {preview.dist.toFixed(1)} NM</span>}
</div>
<>
<div className="dto-tgt"><span className="dto-id">{sel.id}</span><span className="dto-type">{sel.type}</span></div>
<div className="dto-grid">
<b>ALT</b><span>_____FT</span><b>OFFSET</b><span>+0NM</span>
<b>BRG</b><span>{preview ? `${String(Math.round(preview.brg)).padStart(3, '0')}°` : '___°'}</span>
<b>DIS</b><span>{preview ? `${preview.dist.toFixed(1)}NM` : '__._NM'}</span>
<b>CRS</b><span>{preview ? `${String(Math.round(preview.brg)).padStart(3, '0')}°` : '___°'}</span>
<span /><span />
</div>
</>
)}
</div>
<div className="dlg-actions">
+132
View File
@@ -0,0 +1,132 @@
import React, { useState, useEffect } from 'react';
import { num, navSearch, fmsList } from '../api/useXplane.js';
// G1000 ACTIVE FLIGHT PLAN page (MFD page group + PFD window). Shows the shared
// plan as WPT / DTK / DIS / CUM / ALT, active leg in magenta. Edit: type an
// ident to insert/append (resolved via X-Plane navdata), ✕ deletes, tap a row to
// make it the active leg; CLEAR / INVERT / EXPORT(.fms).
const R_NM = 3440.065, rad = (d) => d * Math.PI / 180, deg = (r) => r * 180 / Math.PI;
function distNm(a, b) {
const dLat = rad(b.lat - a.lat), dLon = rad(b.lon - a.lon);
const s = Math.sin(dLat / 2) ** 2 + Math.cos(rad(a.lat)) * Math.cos(rad(b.lat)) * Math.sin(dLon / 2) ** 2;
return 2 * R_NM * Math.asin(Math.min(1, Math.sqrt(s)));
}
function brng(a, b) {
const y = Math.sin(rad(b.lon - a.lon)) * Math.cos(rad(b.lat));
const x = Math.cos(rad(a.lat)) * Math.sin(rad(b.lat)) - Math.sin(rad(a.lat)) * Math.cos(rad(b.lat)) * Math.cos(rad(b.lon - a.lon));
return (deg(Math.atan2(y, x)) + 360) % 360;
}
const fmtHrs = (h) => { const m = Math.round(h * 60); return `${Math.floor(m / 60)}:${String(m % 60).padStart(2, '0')}`; };
export default function FplPage({ xp, full = false, onClose }) {
const { flightPlan, fp, values, exportMsg } = xp;
const wps = flightPlan.waypoints || [];
const active = Math.max(1, Math.min(wps.length - 1, flightPlan.activeLeg ?? 1));
const [entry, setEntry] = useState('');
const [hits, setHits] = useState([]);
const [sel, setSel] = useState(-1); // selected row (insert cursor)
const [plans, setPlans] = useState(null); // saved .fms list (load picker)
const openLoad = async () => setPlans(await fmsList());
useEffect(() => {
const q = entry.trim();
if (q.length < 2 || /[,\s]/.test(q)) { setHits([]); return; }
let alive = true;
navSearch(q).then((r) => alive && setHits(r.slice(0, 6)));
return () => { alive = false; };
}, [entry]);
const addAt = async (ident, index) => {
const id = (ident || '').trim().toUpperCase();
if (!id) return;
const hits2 = await navSearch(id);
const hit = hits2[0];
if (!hit) return;
const next = wps.slice();
next.splice(index == null ? next.length : index, 0, { id: hit.id, lat: hit.lat, lon: hit.lon, type: hit.type || 'WPT', alt: null });
fp.set({ name: 'ACTIVE', waypoints: next, activeLeg: flightPlan.activeLeg ?? 1 });
setEntry(''); setHits([]);
};
const invert = () => {
if (wps.length < 2) return;
fp.set({ name: 'ACTIVE', waypoints: wps.slice().reverse(), activeLeg: 1 });
};
// rows with leg + cumulative distance
let cum = 0;
const rows = wps.map((w, i) => {
const prev = wps[i - 1];
const d = prev ? distNm(prev, w) : 0;
cum += d;
return { w, i, d, cum, dtk: prev ? Math.round(brng(prev, w)) : null, orig: i === 0 };
});
const total = cum;
const gs = num(values.groundspeed) * 1.94384;
const ete = gs > 30 ? total / gs : null;
return (
<div className={`fpl ${full ? 'full' : 'win'}`}>
<div className="fpl-head">
<span>{full ? 'ACTIVE FLIGHT PLAN' : 'FLIGHTPLAN'}</span>
<span className="fpl-tot">{total.toFixed(0)} NM{ete ? ` · ${fmtHrs(ete)}` : ''}</span>
</div>
{!full && wps.length > 0 && (
<div className="fpl-od">{wps[0].id} / {wps[wps.length - 1].id}</div>
)}
<div className="fpl-cols"><span>WPT</span><span>DTK</span><span>DIS</span><span>CUM</span><span>ALT</span></div>
<div className="fpl-rows">
{rows.length === 0 && <div className="fpl-empty"> leer Ident unten eingeben</div>}
{rows.map(({ w, i, d, cum, dtk, orig }) => (
<div key={i} className={`fpl-row ${i === active ? 'act' : ''} ${i === sel ? 'sel' : ''}`}
onClick={() => { setSel(i); if (i >= 1) fp.setActive(i); }}>
<span className="r-wpt"><b className={i === active ? 'cur' : ''}>{w.id}</b><i>{w.type}</i></span>
<span className="r-dtk">{dtk == null ? '___' : `${String(dtk).padStart(3, '0')}°`}</span>
<span className="r-dis">{orig ? '—' : d.toFixed(1)}</span>
<span className="r-cum">{orig ? '—' : cum.toFixed(0)}</span>
<span className="r-alt">{w.alt ? `${w.alt}` : '_____'}</span>
<button className="r-del" onClick={(e) => { e.stopPropagation(); fp.remove(i); }}></button>
</div>
))}
</div>
<div className="fpl-entry">
{hits.length > 0 && (
<div className="fpl-hits">
{hits.map((h) => (
<button key={h.id + h.lat} onClick={() => addAt(h.id, sel >= 0 ? sel : null)}>
<b>{h.id}</b><i>{h.type}</i><span>{h.lat.toFixed(2)}, {h.lon.toFixed(2)}</span>
</button>
))}
</div>
)}
<div className="fpl-inrow">
<input value={entry} onChange={(e) => setEntry(e.target.value.toUpperCase())}
onKeyDown={(e) => e.key === 'Enter' && addAt(entry, sel >= 0 ? sel : null)}
placeholder={sel >= 0 ? `Einfügen vor #${sel + 1}` : 'IDENT anhängen (z.B. ELN)'}
autoCapitalize="characters" autoCorrect="off" spellCheck="false" />
<button className="fpl-btn add" onClick={() => addAt(entry, sel >= 0 ? sel : null)}>EINFÜGEN</button>
</div>
<div className="fpl-actions">
<button className="fpl-btn" onClick={openLoad}>LADEN</button>
<button className="fpl-btn" onClick={() => { setSel(-1); fp.clear(); }} disabled={!wps.length}>CLEAR</button>
<button className="fpl-btn" onClick={invert} disabled={wps.length < 2}>INVERT</button>
<button className="fpl-btn" onClick={() => fp.export('WEBFPL')} disabled={wps.length < 2}>EXPORT .fms</button>
</div>
{exportMsg && <div className={`fpl-msg ${exportMsg.ok ? 'ok' : 'err'}`}>{exportMsg.ok ? 'Exportiert ✓' : exportMsg.error}</div>}
</div>
{plans && (
<div className="fpl-load" onClick={() => setPlans(null)}>
<div className="fpl-load-box" onClick={(e) => e.stopPropagation()}>
<div className="fpl-load-head"><span>Gespeicherte Flugpläne</span><button onClick={() => setPlans(null)}></button></div>
<div className="fpl-load-list">
{plans.length === 0 && <div className="fpl-empty">keine .fms in Output/FMS plans"</div>}
{plans.map((n) => (
<button key={n} onClick={() => { fp.load(n); setPlans(null); }}>{n}<i>.fms</i></button>
))}
</div>
</div>
</div>
)}
</div>
);
}
+69 -21
View File
@@ -1,26 +1,67 @@
import React, { useState } from 'react';
import { num } from '../api/useXplane.js';
import MapView from './MapView.jsx';
import Nearest from './Nearest.jsx';
import FplPage from './FplPage.jsx';
const arr = (v, i = 0, d = 0) => (Array.isArray(v) ? num(v[i], d) : num(v, d));
const KG_PER_GAL = 2.72; // avgas
const navF = (v) => (num(v) / 100).toFixed(2);
const comF = (v) => (num(v) / 100).toFixed(3);
// Active flight-plan leg: distance / desired track / ETE to the active waypoint
// (great-circle from the aircraft), for the MFD nav data bar. Mirrors the PFD's
// activeNav so the two displays agree.
const R_NM = 3440.065, D2R = Math.PI / 180, R2D = 180 / Math.PI;
function legNav(V, fp) {
const wps = fp?.waypoints || [];
const ai = Math.max(1, Math.min(wps.length - 1, fp?.activeLeg ?? 1));
const wp = wps[ai];
const lat = num(V.lat), lon = num(V.lon);
if (!wp || (!lat && !lon)) return null;
const dLat = (wp.lat - lat) * D2R, dLon = (wp.lon - lon) * D2R;
const a = Math.sin(dLat / 2) ** 2 + Math.cos(lat * D2R) * Math.cos(wp.lat * D2R) * Math.sin(dLon / 2) ** 2;
const dist = 2 * R_NM * Math.asin(Math.min(1, Math.sqrt(a)));
const y = Math.sin(dLon) * Math.cos(wp.lat * D2R);
const x = Math.cos(lat * D2R) * Math.sin(wp.lat * D2R) - Math.sin(lat * D2R) * Math.cos(wp.lat * D2R) * Math.cos(dLon);
const dtk = (Math.atan2(y, x) * R2D + 360) % 360;
const gs = num(V.groundspeed) * 1.94384;
return { id: wp.id, dist, dtk, ete: gs > 20 ? (dist / gs) * 3600 : null };
}
const fmtEte = (s) => {
if (s == null) return '__:__';
const m = Math.floor(s / 60), ss = Math.round(s % 60);
return m < 60 ? `${m}:${String(ss).padStart(2, '0')}` : `${Math.floor(m / 60)}+${String(m % 60).padStart(2, '0')}`;
};
// G1000 MFD — full-width NAV/COM bar on top, the engine instrument strip (EIS)
// down the left as real bar gauges, and the moving map (X-Plane nav data) with
// G1000 chrome (compass rose, range, NORTH UP, mode) filling the rest.
export default function MFD({ values: V, flightPlan, fp, mapMode }) {
const MFD_PAGES = [{ id: 'map', name: 'MAP' }, { id: 'fpl', name: 'FPL' }, { id: 'nrst', name: 'NRST' }];
export default function MFD({ values: V, flightPlan, fp, mapMode, page = 'map', onCycle, xp }) {
const [rangeNm, setRangeNm] = useState(8);
const idx = Math.max(0, MFD_PAGES.findIndex((p) => p.id === page));
return (
<div className="mfd-g1000">
<MfdTopBar V={V} />
<MfdTopBar V={V} fp={flightPlan} />
<div className="mfd-body">
<EisStrip V={V} />
<div className="mfd-map">
<MapView values={V} flightPlan={flightPlan} fp={fp} hud={false}
mapMode={mapMode} dcltr={mapMode?.dcltr || 0} onView={({ rangeNm }) => setRangeNm(rangeNm)} />
<MapChrome V={V} rangeNm={rangeNm} />
{/* MapView stays mounted (keeps tiles warm) but is hidden under NRST */}
<div style={{ position: 'absolute', inset: 0, visibility: page === 'map' ? 'visible' : 'hidden' }}>
<MapView values={V} flightPlan={flightPlan} fp={fp} hud={false}
mapMode={mapMode} dcltr={mapMode?.dcltr || 0} rangeNm={num(V.uiMapRange) || undefined}
terrain={xp?.terrain} rose onView={({ rangeNm }) => setRangeNm(rangeNm)} />
<MapChrome V={V} rangeNm={rangeNm} />
</div>
{page === 'nrst' && <Nearest values={V} full />}
{page === 'fpl' && xp && <FplPage xp={xp} full />}
{/* page-group indicator (bottom-right), like the real G1000 — selected
by the FMS knob; tappable as a touch fallback. */}
<button className="mfd-pageind" onClick={() => onCycle && onCycle(1)} title="Seite (FMS-Knopf)">
<span>{MFD_PAGES[idx].name}</span>
{MFD_PAGES.map((p, i) => <em key={p.id} className={i === idx ? 'on' : ''} />)}
</button>
</div>
</div>
</div>
@@ -28,9 +69,11 @@ export default function MFD({ values: V, flightPlan, fp, mapMode }) {
}
/* ---------------- top NAV/COM bar ---------------- */
function MfdTopBar({ V }) {
function MfdTopBar({ V, fp }) {
const gs = Math.round(num(V.groundspeed) * 1.94384);
const trk = String(Math.round(num(V.track)) % 360).padStart(3, '0');
const leg = legNav(V, fp);
const dtk = leg ? `${String(Math.round(leg.dtk) % 360).padStart(3, '0')}°` : '___°';
const swap = (x, y) => <text x={x} y={y} fill="#0ff" fontSize="16" textAnchor="middle"></text>;
return (
<svg className="mfd-topbar" viewBox="0 0 1000 70" preserveAspectRatio="none" fontFamily="monospace">
@@ -51,10 +94,22 @@ function MfdTopBar({ V }) {
<text x="350" y="27" fill="#e040fb" fontSize="15" fontWeight="bold">{gs}</text>
<text x="378" y="27" fill="#0c9" fontSize="11">KT</text>
<text x="410" y="27" fill="#fff" fontSize="13">DTK</text>
<text x="448" y="27" fill="#e040fb" fontSize="15" fontWeight="bold">{dtk}</text>
<text x="520" y="27" fill="#fff" fontSize="13">TRK</text>
<text x="560" y="27" fill="#e040fb" fontSize="15" fontWeight="bold">{trk}°</text>
<text x="610" y="27" fill="#fff" fontSize="13">ETE</text>
<text x="480" y="58" fill="#0ff" fontSize="15" textAnchor="middle">NAV DEFAULT NAV</text>
<text x="648" y="27" fill="#fff" fontSize="15">{fmtEte(leg?.ete)}</text>
{/* active leg (centre): → waypoint + distance, or no-flight-plan note */}
{leg ? (
<g>
<text x="412" y="58" fill="#e040fb" fontSize="16"></text>
<text x="432" y="58" fill="#fff" fontSize="16" fontWeight="bold">{leg.id}</text>
<text x="520" y="58" fill="#0ff" fontSize="15">{leg.dist.toFixed(1)}</text>
<text x="566" y="58" fill="#0c9" fontSize="11">NM</text>
</g>
) : (
<text x="480" y="58" fill="#777" fontSize="14" textAnchor="middle">NO ACTIVE WAYPOINT</text>
)}
{/* COM1 / COM2 */}
<text x="690" y="27" fill="#0f0" fontSize="17">{comF(V.com1)}</text>
{swap(818, 27)}
@@ -191,24 +246,17 @@ function niceRange(nm) { let r = NICE[0]; for (const s of NICE) if (nm >= s) r =
function MapChrome({ V, rangeNm }) {
const gs = Math.round(num(V.groundspeed) * 1.94384);
const rng = niceRange(rangeNm);
const cx = 160, cy = 160, r = 150;
const ticks = [];
for (let d = 0; d < 360; d += 10) {
const a = ((d - 90) * Math.PI) / 180;
const big = d % 30 === 0;
const r2 = r - (big ? 12 : 7);
ticks.push(<line key={d} x1={cx + r * Math.cos(a)} y1={cy + r * Math.sin(a)} x2={cx + r2 * Math.cos(a)} y2={cy + r2 * Math.sin(a)} stroke="#cfd6dd" strokeWidth={big ? 2 : 1} />);
if (big) {
const lbl = d === 0 ? 'N' : d === 90 ? 'E' : d === 180 ? 'S' : d === 270 ? 'W' : d / 10;
ticks.push(<text key={'l' + d} x={cx + (r - 26) * Math.cos(a)} y={cy + (r - 26) * Math.sin(a) + 5} fill="#fff" fontSize="15" textAnchor="middle" fontFamily="monospace">{lbl}</text>);
}
}
const wd = ((Math.round(num(V.windDir)) % 360) + 360) % 360, ws = Math.round(num(V.windSpd));
return (
<div className="map-chrome">
<svg className="map-rose" viewBox="0 0 320 320">{ticks}</svg>
{/* the compass rose now lives in MapView, anchored to the aircraft */}
<div className="mc-tr"><b>{gs} KT</b><span>NORTH UP</span></div>
<div className="mc-wind">
{ws >= 1
? (<><span className="mc-windarr" style={{ transform: `rotate(${wd + 180}deg)` }}></span><span>{String(wd).padStart(3, '0')}° {ws}<i>kt</i></span></>)
: <span>CALM</span>}
</div>
<div className="mc-range">{rng} NM</div>
<div className="mc-mode">NAV <em className="on" /><em /><em /><em /><em /></div>
</div>
);
}
+121 -5
View File
@@ -7,6 +7,20 @@ const PLANE_SVG =
'<svg viewBox="0 0 24 24" width="34" height="34"><path fill="#ffd400" stroke="#000" stroke-width="1" ' +
'd="M12 2l1.5 6.5L22 13v2l-8.5-2.5L13 21l2 1v1l-3-1-3 1v-1l2-1-.5-8.5L2 15v-2l8.5-4.5z"/></svg>';
// Compass rose anchored to the ownship (north-up), built once. As a Leaflet
// marker it tracks the aircraft on every pan — so it always wraps the plane
// instead of drifting with the screen.
const ROSE_PX = 360;
const ROSE_HTML = (() => {
const cx = 180, cy = 180, r = 170; let t = '';
for (let d = 0; d < 360; d += 10) {
const a = ((d - 90) * Math.PI) / 180, big = d % 30 === 0, r2 = r - (big ? 13 : 7);
t += `<line x1="${cx + r * Math.cos(a)}" y1="${cy + r * Math.sin(a)}" x2="${cx + r2 * Math.cos(a)}" y2="${cy + r2 * Math.sin(a)}" stroke="#cfd6dd" stroke-width="${big ? 2 : 1}"/>`;
if (big) { const lbl = d === 0 ? 'N' : d === 90 ? 'E' : d === 180 ? 'S' : d === 270 ? 'W' : d / 10; t += `<text x="${cx + (r - 28) * Math.cos(a)}" y="${cy + (r - 28) * Math.sin(a) + 5}" fill="#fff" font-size="16" text-anchor="middle" font-family="monospace">${lbl}</text>`; }
}
return `<svg width="${ROSE_PX}" height="${ROSE_PX}" viewBox="0 0 360 360">${t}</svg>`;
})();
// A single nav feature rendered as G1000-style symbology: cyan airport, green
// VOR hexagon, brown NDB dot-ring, light fix triangle — with an optional label.
function navSymbol(f, label) {
@@ -43,15 +57,20 @@ const TILES = {
dark: null,
};
export default function MapView({ values, flightPlan, fp, inset = false, hud = true, mapMode, dcltr = 0, onView }) {
export default function MapView({ values, flightPlan, fp, inset = false, hud = true, mapMode, dcltr = 0, onView, rangeNm, terrain, rose = false }) {
const elRef = useRef(null);
const mapRef = useRef(null);
const acRef = useRef(null);
const roseRef = useRef(null);
const routeRef = useRef(null);
const wpLayerRef = useRef(null);
const navLayerRef = useRef(null);
const navAbortRef = useRef(null);
const awyLayerRef = useRef(null);
const awyOnRef = useRef(false);
const refreshAirwaysRef = useRef(null);
const baseRef = useRef(null);
const terrRef = useRef(null);
const [follow, setFollow] = useState(true);
const followRef = useRef(true);
followRef.current = follow;
@@ -61,8 +80,10 @@ export default function MapView({ values, flightPlan, fp, inset = false, hud = t
const track = num(values.track);
const gs = num(values.groundspeed) * 1.94384; // m/s -> kt
const base = mapMode?.base || 'topo';
const airways = !!mapMode?.airways;
const dcltrRef = useRef(dcltr);
dcltrRef.current = dcltr;
awyOnRef.current = airways;
// Swap the base tile layer (and report it via the container's dark class).
const applyBase = (map, name) => {
@@ -82,14 +103,49 @@ export default function MapView({ values, flightPlan, fp, inset = false, hud = t
// create map once
useEffect(() => {
const map = L.map(elRef.current, { zoomControl: !inset, attributionControl: false, dragging: !inset, scrollWheelZoom: !inset })
const map = L.map(elRef.current, { zoomControl: !inset, attributionControl: false, dragging: !inset, scrollWheelZoom: !inset, zoomSnap: 0 })
.setView([lat, lon], inset ? 10 : 9);
applyBase(map, base);
navLayerRef.current = L.layerGroup().addTo(map); // real airports/navaids/fixes
// dedicated pane for the terrain-awareness overlay: above the base tiles
// (z 200) but below the route / nav symbols (overlayPane z 400)
map.createPane('terrain');
map.getPane('terrain').style.zIndex = 250;
map.getPane('terrain').style.pointerEvents = 'none';
awyLayerRef.current = L.layerGroup().addTo(map); // airways (under everything else)
navLayerRef.current = L.layerGroup().addTo(map); // real airports/navaids/fixes
routeRef.current = L.layerGroup().addTo(map); // flight-plan legs (white + magenta active)
wpLayerRef.current = L.layerGroup().addTo(map);
// AIRWAYS overlay (Victor/Jet routes from X-Plane's earth_awy.dat). Light
// blue lines with the airway name at the segment midpoint (labels ≥ z8).
const refreshAirways = async () => {
const layer = awyLayerRef.current;
if (!layer) return;
if (!awyOnRef.current) { layer.clearLayers(); return; }
const b = map.getBounds();
try {
const res = await fetch(`/api/nav/airways?s=${b.getSouth()}&w=${b.getWest()}&n=${b.getNorth()}&e=${b.getEast()}&limit=600`);
if (!res.ok) return;
const segs = await res.json();
layer.clearLayers();
const labels = map.getZoom() >= 8;
const seen = new Set();
for (const sg of segs) {
L.polyline([[sg.la1, sg.lo1], [sg.la2, sg.lo2]], { color: '#5db4e6', weight: 1.2, opacity: 0.7, interactive: false }).addTo(layer);
if (labels && sg.name && !seen.has(sg.name)) {
seen.add(sg.name);
L.marker([(sg.la1 + sg.la2) / 2, (sg.lo1 + sg.lo2) / 2], {
icon: L.divIcon({ className: 'awy-divicon', html: `<span class='awy-lbl'>${sg.name}</span>`, iconSize: [0, 0] }),
interactive: false,
}).addTo(layer);
}
}
} catch { /* offline */ }
};
refreshAirwaysRef.current = refreshAirways;
// Pull X-Plane's own nav data for the current view and draw it as G1000-style
// vector symbology (cyan airports, green VOR hexagons, NDB dot-rings, fixes).
const refreshNav = async () => {
@@ -111,10 +167,17 @@ export default function MapView({ values, flightPlan, fp, inset = false, hud = t
for (const f of feats) navSymbol(f, labels).addTo(layer);
} catch { /* aborted or offline — leave as is */ }
};
map.on('moveend', () => { refreshNav(); reportView(map); });
map.on('moveend', () => { refreshNav(); refreshAirways(); reportView(map); });
map.on('zoomend', () => reportView(map));
setTimeout(() => { refreshNav(); reportView(map); }, 300);
setTimeout(() => { refreshNav(); refreshAirways(); reportView(map); }, 300);
// compass rose anchored to the aircraft (north-up) — tracks the ownship
if (rose) {
roseRef.current = L.marker([lat, lon], {
icon: L.divIcon({ className: 'rose-divicon', html: ROSE_HTML, iconSize: [ROSE_PX, ROSE_PX], iconAnchor: [ROSE_PX / 2, ROSE_PX / 2] }),
interactive: false, zIndexOffset: 600, pane: 'terrain',
}).addTo(map);
}
const icon = L.divIcon({ className: 'ac-divicon', html: PLANE_SVG, iconSize: [34, 34], iconAnchor: [17, 17] });
acRef.current = L.marker([lat, lon], { icon, interactive: false, zIndexOffset: 1000 }).addTo(map);
@@ -134,6 +197,58 @@ export default function MapView({ values, flightPlan, fp, inset = false, hud = t
if (map) applyBase(map, base);
}, [base]); // eslint-disable-line
// redraw airways when the AIRWAYS toggle changes
useEffect(() => { refreshAirwaysRef.current && refreshAirwaysRef.current(); }, [airways]); // eslint-disable-line
// TERRAIN AWARENESS overlay: colour the elevation grid (from the FlyWithLua
// terrain probe) relative to aircraft altitude — red within 100 ft below/above,
// yellow 1001000 ft below, transparent otherwise (G1000 TAWS colours). Only
// when the TERRAIN base is selected.
useEffect(() => {
const map = mapRef.current;
if (!map) return;
const t = terrain;
const show = base === 'terrain' && t && Array.isArray(t.elev) && t.elev.length === t.rows * t.cols;
if (!show) {
if (terrRef.current) { map.removeLayer(terrRef.current); terrRef.current = null; }
return;
}
const cv = document.createElement('canvas');
cv.width = t.cols; cv.height = t.rows;
const ctx = cv.getContext('2d');
const img = ctx.createImageData(t.cols, t.rows);
for (let i = 0; i < t.elev.length; i++) {
const ev = t.elev[i], diff = ev - t.alt;
let R = 0, G = 0, B = 0, A = 0;
if (ev > 0) {
if (diff > -100) { R = 214; G = 22; B = 22; A = 185; } // red
else if (diff > -1000) { R = 216; G = 168; B = 20; A = 150; } // yellow
}
const p = i * 4; img.data[p] = R; img.data[p + 1] = G; img.data[p + 2] = B; img.data[p + 3] = A;
}
ctx.putImageData(img, 0, 0);
const bounds = [[t.s, t.w], [t.n, t.e]];
if (!terrRef.current) {
terrRef.current = L.imageOverlay(cv.toDataURL(), bounds, { opacity: 0.6, interactive: false, pane: 'terrain' }).addTo(map);
} else {
terrRef.current.setBounds(bounds);
terrRef.current.setUrl(cv.toDataURL());
}
}, [terrain, base]); // eslint-disable-line
// G1000 UI sync: follow the in-sim map range (centre→top-edge NM). Inverse of
// reportView: solve for the zoom that yields the target range at this latitude
// and viewport height. Only when the sim publishes it (rangeNm > 0).
useEffect(() => {
const map = mapRef.current;
if (!map || !(rangeNm > 0)) return;
const H = map.getSize().y;
if (!H) return;
const z = Math.log2((156543.03392 * Math.cos(lat * Math.PI / 180) * (H / 2)) / (rangeNm * 1852));
const zc = Math.max(3, Math.min(17, z));
if (Math.abs(map.getZoom() - zc) > 0.04) map.setZoom(zc, { animate: true });
}, [rangeNm]); // eslint-disable-line
// declutter: hide nav symbology, or repopulate it, when the level changes
useEffect(() => {
const map = mapRef.current;
@@ -147,6 +262,7 @@ export default function MapView({ values, flightPlan, fp, inset = false, hud = t
const ac = acRef.current, map = mapRef.current;
if (!ac || !map) return;
ac.setLatLng([lat, lon]);
roseRef.current?.setLatLng([lat, lon]);
const el = ac.getElement()?.querySelector('svg');
if (el) el.style.transform = `rotate(${track}deg)`;
if (followRef.current) map.panTo([lat, lon], { animate: true, duration: 0.5 });
+29 -21
View File
@@ -19,7 +19,7 @@ const freqStr = (f, type) => {
return type === 'vor' ? (n / 100).toFixed(2) : String(n);
};
export default function Nearest({ values, onClose }) {
export default function Nearest({ values, onClose, full = false }) {
const [type, setType] = useState('apt');
const [rows, setRows] = useState([]);
const lastRef = useRef(null);
@@ -42,35 +42,43 @@ export default function Nearest({ values, onClose }) {
}, [type, Math.round(lat * 50), Math.round(lon * 50)]); // re-key on ~1nm moves
return (
<div className="nrst-window">
<div className={`nrst-window ${full ? 'full' : ''}`}>
<div className="nrst-head">
<span className="nrst-title">NEAREST</span>
<span className="nrst-title">NEAREST {type === 'apt' ? 'AIRPORTS' : type === 'vor' ? 'VOR' : 'NDB'}</span>
<div className="nrst-tabs">
{TABS.map((t) => (
<button key={t.id} className={type === t.id ? 'on' : ''} onClick={() => setType(t.id)}>{t.label}</button>
))}
</div>
{onClose && <button className="nrst-x" onClick={onClose}></button>}
</div>
<div className="nrst-cols">
<span className="c-id">{type === 'apt' ? 'IDENT' : 'IDENT'}</span>
<span className="c-brg">BRG</span>
<span className="c-dis">DIS</span>
<span className="c-xtra">{type === 'apt' ? 'ELEV' : 'FREQ'}</span>
</div>
<div className="nrst-list">
{rows.length === 0 && <div className="nrst-empty"> no data </div>}
{rows.map((f, i) => (
<div className="nrst-row" key={f.id + i}>
<span className="c-id">{f.id}</span>
<span className="c-brg">{String(num(f.brg)).padStart(3, '0')}°</span>
<span className="c-dis">{num(f.dist).toFixed(1)}<u>nm</u></span>
<span className="c-xtra">
{type === 'apt' ? `${Math.round(num(f.elev))}ft` : freqStr(f.freq, type)}
</span>
{f.name && <span className="c-name">{f.name}</span>}
</div>
))}
{type === 'apt'
? rows.map((f, i) => (
<div className="apt-entry" key={f.id + i}>
<div className="apt-l1">
<span className={`apt-id ${i === 0 ? 'cur' : ''}`}>{f.id}</span>
<span className="apt-brg">{String(num(f.brg)).padStart(3, '0')}°</span>
<span className="apt-dis">{num(f.dist).toFixed(1)}<u>NM</u></span>
<span className={`apt-app ${f.app === 'ILS' ? 'ils' : ''}`}>{f.app || 'VFR'}</span>
</div>
<div className="apt-l2">
<span className="apt-comlbl">{f.com ? f.com.label : ''}</span>
<span className="apt-com">{f.com ? f.com.freq.toFixed(3) : ''}</span>
<span className="apt-rwlbl">RNWY</span>
<span className="apt-rw">{f.rwyFt ? `${f.rwyFt}FT` : '—'}</span>
</div>
</div>
))
: rows.map((f, i) => (
<div className="nrst-row" key={f.id + i}>
<span className="c-id">{f.id}</span>
<span className="c-brg">{String(num(f.brg)).padStart(3, '0')}°</span>
<span className="c-dis">{num(f.dist).toFixed(1)}<u>nm</u></span>
<span className="c-xtra">{freqStr(f.freq, type)}</span>
{f.name && <span className="c-name">{f.name}</span>}
</div>
))}
</div>
</div>
);
+339 -47
View File
@@ -1,8 +1,9 @@
import React, { useRef, useState, useEffect, useLayoutEffect, Suspense, lazy } from 'react';
import { num } from '../api/useXplane.js';
import { num, systemAlerts } from '../api/useXplane.js';
import MapView from './MapView.jsx';
import Nearest from './Nearest.jsx';
import TimerRef from './TimerRef.jsx';
import RadioTuner from './RadioTuner.jsx';
// Lazy-load the heavy WebGL terrain engine only when the PFD is shown.
const SVT = lazy(() => import('./SVT.jsx'));
@@ -87,7 +88,51 @@ const SVT_BOX = { x: 0, y: 74, w: W, h: H - 74 };
// The INSET moving map sits in the bottom-left corner (toggled by INSET softkey).
const INSET_BOX = { x: 6, y: 556, w: 300, h: 172 };
export default function PFD({ values: V, svt = true, inset = false, insetMode, nrst = false, onCloseNrst, tmr = false, onCloseTmr, flightPlan, fp }) {
// Frame-rate-independent easing of a scalar toward a moving target (alpha from
// dt + a time constant). Re-renders the consumer only while it's moving —
// setState bails out when the value has settled. Used to glide the speed/alt
// tapes and the heading rose, just like the imperative attitude smoothing.
function useEased(target, tau = 0.08) {
const [v, setV] = useState(target);
const cur = useRef(target), tg = useRef(target);
tg.current = target;
useEffect(() => {
let raf, last = 0;
const loop = (now) => {
const dt = last ? Math.min(0.05, (now - last) / 1000) : 0.016; last = now;
const k = 1 - Math.exp(-dt / tau);
const next = cur.current + (tg.current - cur.current) * k;
cur.current = Math.abs(tg.current - next) < 0.02 ? tg.current : next;
setV(cur.current);
raf = requestAnimationFrame(loop);
};
raf = requestAnimationFrame(loop);
return () => cancelAnimationFrame(raf);
}, [tau]);
return v;
}
// As above but eases along the shortest arc across the 0↔360 wrap (headings).
function useEasedAngle(target, tau = 0.08) {
const [v, setV] = useState(target);
const cur = useRef(target), tg = useRef(target);
tg.current = target;
useEffect(() => {
let raf, last = 0;
const loop = (now) => {
const dt = last ? Math.min(0.05, (now - last) / 1000) : 0.016; last = now;
const k = 1 - Math.exp(-dt / tau);
const d = ((tg.current - cur.current + 540) % 360) - 180; // shortest signed arc
cur.current = Math.abs(d) < 0.05 ? tg.current : cur.current + d * k;
setV(((cur.current % 360) + 360) % 360);
raf = requestAnimationFrame(loop);
};
raf = requestAnimationFrame(loop);
return () => cancelAnimationFrame(raf);
}, [tau]);
return v;
}
export default function PFD({ values: V, command, svt = true, inset = false, insetMode, nrst = false, onCloseNrst, tmr = false, onCloseTmr, dme = false, onCloseDme, alerts = false, onCloseAlerts, flightPlan, fp }) {
const wrapRef = useRef(null);
const svgRef = useRef(null);
const [box, setBox] = useState(null);
@@ -116,6 +161,13 @@ export default function PFD({ values: V, svt = true, inset = false, insetMode, n
const nav = activeNav(V, flightPlan);
const vnav = vnavInfo(V, flightPlan);
const [tune, setTune] = useState(null); // radio being tuned (tap a freq)
// Eased values so the tapes + heading rose glide between X-Plane's ~20 Hz
// samples (VSI a touch softer; attitude is smoothed separately, imperatively).
const iasS = useEased(num(V.airspeed));
const altS = useEased(num(V.altitude));
const vsS = useEased(num(V.vspeed), 0.12);
const hdgS = useEasedAngle(((num(V.heading) % 360) + 360) % 360);
return (
<div className="pfd-wrap" ref={wrapRef}>
@@ -127,7 +179,7 @@ export default function PFD({ values: V, svt = true, inset = false, insetMode, n
{inset && insetBox && (
<div className="pfd-inset" style={insetBox}>
<MapView values={V} flightPlan={flightPlan} fp={fp} inset
mapMode={insetMode} dcltr={insetMode?.dcltr || 0} />
mapMode={insetMode} dcltr={insetMode?.dcltr || 0} rangeNm={num(V.uiMapRange) || undefined} />
</div>
)}
<svg ref={svgRef} className="g1000" viewBox={`0 0 ${W} ${H}`} preserveAspectRatio="xMidYMid meet">
@@ -140,19 +192,25 @@ export default function PFD({ values: V, svt = true, inset = false, insetMode, n
</linearGradient>
</defs>
{!svt && <rect x="0" y="0" width={W} height={H} fill="#000" />}
<RadioBar V={V} />
<RadioBar V={V} onTune={command ? setTune : null} />
{nav && <NavStatus nav={nav} />}
{vnav && <VnavBox vnav={vnav} />}
<Attitude V={V} svt={svt} />
<AirspeedTape V={V} />
<AltitudeTape V={V} />
<AFCS V={V} />
<Marker V={V} />
<AirspeedTape V={V} ias={iasS} />
<AltitudeTape V={V} alt={altS} vs={vsS} />
<GlideSlope V={V} />
<HSI V={V} nav={nav} />
<HSI V={V} nav={nav} hdg={hdgS} />
<HdgCrsBoxes V={V} nav={nav} />
<Wind V={V} />
<DataStrip V={V} />
</svg>
{nrst && <Nearest values={V} onClose={onCloseNrst} />}
{tmr && <TimerRef values={V} onClose={onCloseTmr} />}
{dme && <DmeWindow V={V} onClose={onCloseDme} />}
{alerts && <AlertsWindow V={V} onClose={onCloseAlerts} />}
{tune && <RadioTuner values={V} command={command} radio={tune} onClose={() => setTune(null)} />}
</div>
);
}
@@ -161,7 +219,7 @@ export default function PFD({ values: V, svt = true, inset = false, insetMode, n
// Matches the XPLANE 1000: NAV cyan (active boxed), COM green active /
// cyan-boxed standby, a centre flight-plan cell with DIS/BRG, ⇄ swap arrows.
const SWAP = '⇔';
function RadioBar({ V }) {
function RadioBar({ V, onTune }) {
const swap = (x, y) => <text x={x} y={y} fill="#0ff" fontSize="17" fontFamily="monospace" textAnchor="middle">{SWAP}</text>;
return (
<g fontFamily="monospace">
@@ -200,6 +258,113 @@ function RadioBar({ V }) {
{swap(844, 60)}
<text x="950" y="60" fill="#fff" fontSize="19" textAnchor="end">{comF(V.com2Sb)}</text>
<text x={W - 6} y="58" fill="#9aa" fontSize="12" textAnchor="end">COM2</text>
{/* tap a radio to open the touch tuner (big hit areas) */}
{onTune && (
<g fill="transparent" style={{ cursor: 'pointer' }}>
<rect x="6" y="8" width="320" height="26" onClick={() => onTune({ id: 'nav1', label: 'NAV1', isCom: false })} />
<rect x="6" y="40" width="320" height="26" onClick={() => onTune({ id: 'nav2', label: 'NAV2', isCom: false })} />
<rect x="700" y="8" width="298" height="26" onClick={() => onTune({ id: 'com1', label: 'COM1', isCom: true })} />
<rect x="700" y="40" width="298" height="26" onClick={() => onTune({ id: 'com2', label: 'COM2', isCom: true })} />
</g>
)}
</g>
);
}
/* ---------------- DME tuning window (DME softkey) ---------------- */
// Mirrors the G1000 DME window: source (NAV1/NAV2/HOLD), frequency, slant
// distance, groundspeed and time-to-station — all from the sim's DME datarefs.
function DmeWindow({ V, onClose }) {
const [src, setSrc] = useState('NAV1');
const isN2 = src === 'NAV2';
const dis = num(isN2 ? V.nav2Dme : V.nav1Dme);
const freq = isN2 ? V.nav2 : V.nav1;
const gs = num(V.groundspeed) * 1.94384;
const min = dis > 0 && gs > 30 ? (dis / gs) * 60 : null;
return (
<div className="pfd-pop dme">
<div className="nrst-head"><span className="nrst-title">DME</span></div>
<div className="pop-grid">
<b>MODE</b><span>{src}</span>
<b>FREQ</b><span>{navF(freq)}</span>
<b>DIS</b><span>{dis > 0 ? `${dis.toFixed(1)} NM` : ' '}</span>
<b>GS</b><span>{Math.round(gs)} KT</span>
<b>TIME</b><span>{min != null ? `${Math.floor(min)}:${String(Math.round((min % 1) * 60)).padStart(2, '0')}` : ' '}</span>
</div>
<div className="pop-tabs">
{['NAV1', 'NAV2', 'HOLD'].map((s) => (
<button key={s} className={src === s ? 'on' : ''} onClick={() => setSrc(s)}>{s}</button>
))}
</div>
</div>
);
}
/* ---------------- ALERTS / messages window (CAUTION softkey) ---------------- */
function AlertsWindow({ V, onClose }) {
const msgs = systemAlerts(V);
return (
<div className="pfd-pop alerts">
<div className="nrst-head"><span className="nrst-title">MESSAGES</span></div>
<div className="alerts-list">
{msgs.length === 0
? <div className="alert-none">NO MESSAGES</div>
: msgs.map((m) => <div key={m.t} className={`alert-row ${m.warn ? 'warn' : 'cau'}`}>{m.t}</div>)}
</div>
</div>
);
}
/* ---------------- AFCS mode annunciation bar ----------------
The green/white mode strip across the top of a real G1000: AP/FD in the
centre, the lateral mode pair on the left (active green, armed white) and the
vertical mode pair on the right (active + reference, armed). Driven by
X-Plane's per-mode _status datarefs (2 = active, 1 = armed), so it mirrors
the sim's autopilot exactly. */
function AFCS({ V }) {
const st = (k) => Math.round(num(V[k]));
const apMode = st('apMode');
const ap = apMode >= 2 || num(V.apEngaged) > 0;
const fd = apMode >= 1 || ap;
// resolve an active (green) + armed (white) label from a priority list
const pick = (entries) => {
let act = '', arm = '';
for (const [lbl, s] of entries) {
if (s === 2 && !act) act = lbl;
else if (s === 1 && !arm) arm = lbl;
}
return { act, arm };
};
const lat = pick([
['GPS', st('gpssStatus')], ['LOC', st('aprStatus')], ['VOR', st('navStatus')],
['BC', st('bcStatus')], ['HDG', st('hdgStatus')],
]);
const vrt = pick([
['GS', st('gsStatus')], ['ALT', st('altStatus')], ['VS', st('vsStatus')],
['FLC', st('flcStatus')], ['VNV', st('vnavStatus')],
]);
if (!lat.act && fd) lat.act = 'ROL'; // wings-level default
if (!vrt.act && fd) vrt.act = 'PIT'; // pitch-hold default
// reference value beside the active vertical mode
const ref = vrt.act === 'ALT' ? `${Math.round(num(V.apAltBug))}FT`
: vrt.act === 'VS' ? `${Math.round(num(V.apVsBug))}FPM`
: vrt.act === 'FLC' ? `${Math.round(num(V.apSpdBug))}KT` : '';
const cx = W / 2, yb = 98; // baseline
return (
<g fontFamily="monospace" fontSize="17">
<rect x="150" y="78" width={W - 300} height="28" fill="#000" fillOpacity="0.55" />
{/* AP / FD status (centre) */}
<rect x={cx - 30} y="80" width="60" height="24" fill="none" stroke={ap ? '#16c116' : '#555'} strokeWidth="1.4" />
<text x={cx - 16} y={yb} fill={ap ? '#16c116' : '#777'} textAnchor="middle">AP</text>
<text x={cx + 16} y={yb} fill={fd ? '#16c116' : '#777'} textAnchor="middle">FD</text>
{/* lateral: armed (white) then active (green) toward the centre */}
{lat.arm && <text x={cx - 150} y={yb} fill="#fff" textAnchor="middle">{lat.arm}</text>}
{lat.act && <text x={cx - 80} y={yb} fill="#16c116" textAnchor="middle" fontWeight="bold">{lat.act}</text>}
{/* vertical: active (green) + reference, then armed (white) */}
{vrt.act && <text x={cx + 78} y={yb} fill="#16c116" textAnchor="middle" fontWeight="bold">{vrt.act}</text>}
{ref && <text x={cx + 150} y={yb} fill="#16c116" textAnchor="middle">{ref}</text>}
{vrt.arm && <text x={cx + 230} y={yb} fill="#fff" textAnchor="middle">{vrt.arm === 'ALT' ? 'ALTS' : vrt.arm}</text>}
</g>
);
}
@@ -211,14 +376,21 @@ function Attitude({ V, svt }) {
const cx = W / 2, cy = 270;
const rollRef = useRef(null), pitchRef = useRef(null), fdRef = useRef(null), bankRef = useRef(null);
// Target attitude (updated every render); a rAF loop eases the displayed
// transforms toward it at 60 fps — decoupled from X-Plane's ~20 Hz samples,
// so the horizon glides instead of stepping.
// transforms toward it — decoupled from X-Plane's ~20 Hz samples, so the
// horizon glides instead of stepping. The easing is *time-based* (alpha from
// dt and a time constant TAU), so it feels identical at 30/60/120 fps instead
// of being faster on high-refresh screens.
const tgt = useRef({ p: 0, r: 0, fp: 0, fr: 0 });
tgt.current = { p: pitch, r: roll, fp: pitch - fdP, fr: roll - fdR };
useEffect(() => {
let raf; const d = { ...tgt.current };
const loop = () => {
const t = tgt.current, k = 0.4;
let raf, last = 0;
const d = { ...tgt.current };
const TAU = 0.09; // s — smoothing time constant (bigger = silkier, smaller = snappier)
const loop = (now) => {
const dt = last ? Math.min(0.05, (now - last) / 1000) : 0.016; // clamp tab-switch gaps
last = now;
const k = 1 - Math.exp(-dt / TAU); // frame-rate-independent easing factor
const t = tgt.current;
d.p += (t.p - d.p) * k; d.r += (t.r - d.r) * k;
d.fp += (t.fp - d.fp) * k; d.fr += (t.fr - d.fr) * k;
rollRef.current?.setAttribute('transform', `rotate(${-d.r} ${cx} ${cy})`);
@@ -322,8 +494,9 @@ function rollArc(cx, cy, slip, bankRef) {
// V-speed reference marks for the C172 (KIAS), shown below the tape like the
// XPLANE 1000: Vy=74 (Y), Vx=62 (X), best glide=68 (G).
const VSPEEDS = [{ s: 74, l: 'Y' }, { s: 62, l: 'X' }, { s: 68, l: 'G' }];
function AirspeedTape({ V }) {
const ias = num(V.airspeed), tas = num(V.tas), spdBug = num(V.apSpdBug);
function AirspeedTape({ V, ias: iasProp }) {
const ias = iasProp != null ? iasProp : num(V.airspeed);
const tas = num(V.tas), spdBug = num(V.apSpdBug);
const x = 60, top = 110, h = 350, cy = top + h / 2, px = 3.6;
const W2 = 84, sx = x + W2 - 7; // colour strip at the right inner edge
const ticks = [];
@@ -339,6 +512,13 @@ function AirspeedTape({ V }) {
const band = (a, b, color) => <rect x={sx} y={yOf(b)} width={7} height={Math.max(0, yOf(a) - yOf(b))} fill={color} />;
const bugY = Math.max(top, Math.min(top + h, cy + (ias - spdBug) * px));
const valid = ias >= 20;
// magenta airspeed trend vector: 6-second projection from acceleration
// (smoothed dV/dt), exactly like the GDU 1040.
const accRef = useRef({ t: 0, v: ias, a: 0 });
const now = performance.now(), ar = accRef.current;
if (ar.t) { const dt = (now - ar.t) / 1000; if (dt > 0.08) { ar.a = ar.a * 0.7 + ((ias - ar.v) / dt) * 0.3; ar.v = ias; ar.t = now; } }
else { ar.t = now; ar.v = ias; }
const trendY = yOf(ias + ar.a * 6);
return (
<g fontFamily="monospace">
<rect x={x} y={top} width={W2} height={h} fill="#9aa6b3" fillOpacity="0.34" />
@@ -348,22 +528,37 @@ function AirspeedTape({ V }) {
{band(129, 163, '#e0d000')}
<rect x={sx} y={yOf(180)} width={7} height={Math.max(0, yOf(163) - yOf(180))} fill="#d01010" />
{ticks}
{/* magenta speed trend vector (6-sec projection) */}
{valid && Math.abs(trendY - cy) > 2 && (
<rect x={x + W2 - 4} y={Math.min(cy, trendY)} width="4" height={Math.abs(trendY - cy)} fill="#ff20ff" />
)}
{/* selected-airspeed bug (cyan) */}
<path d={`M${x + W2} ${bugY - 7} h-7 v14 h7 z`} fill="none" stroke="#0ff" strokeWidth="2" />
{/* current-speed readout box (points right toward the tape) */}
<polygon points={`${x + W2},${cy} ${x + W2 - 18},${cy - 22} ${x - 30},${cy - 22} ${x - 30},${cy + 22} ${x + W2 - 18},${cy + 22}`}
fill="#000" stroke="#fff" strokeWidth="2" />
<text x={x + W2 - 22} y={cy + 9} textAnchor="end" fill="#fff" fontSize="30" fontWeight="bold">{valid ? Math.round(ias) : '- - -'}</text>
{/* TAS readout directly below the tape, like the real G1000 */}
<text x={x + 4} y={top + h + 22} fill="#fff" fontSize="15">TAS</text>
<text x={x + W2} y={top + h + 22} textAnchor="end" fill="#fff" fontSize="16">{Math.round(tas)}<tspan fontSize="12">KT</tspan></text>
{/* V-speed reference bugs (Vy/Vx/Vg) below the tape like the real G1000 */}
{VSPEEDS.map((v, i) => (
<g key={v.l}>
<text x={x + 40} y={top + h + 25 + i * 22} textAnchor="end" fill="#0ff" fontSize="17">{v.s}</text>
<rect x={x + 46} y={top + h + 12 + i * 22} width="17" height="17" fill="#0ff" />
<text x={x + 54} y={top + h + 25 + i * 22} textAnchor="middle" fill="#000" fontSize="14" fontWeight="bold">{v.l}</text>
</g>
))}
{/* TAS box below the V-speeds */}
<rect x={x} y={top + h + 80} width={W2} height={24} fill="#000" stroke="#3a3a3a" />
<text x={x + 6} y={top + h + 97} fill="#0ff" fontSize="13">TAS</text>
<text x={x + W2 - 6} y={top + h + 97} textAnchor="end" fill="#fff" fontSize="15">{Math.round(tas)}</text>
</g>
);
}
/* ---------------- altitude tape + VSI + baro ---------------- */
function AltitudeTape({ V }) {
const alt = num(V.altitude), vs = num(V.vspeed), altBug = num(V.apAltBug), baro = num(V.baro, 29.92);
function AltitudeTape({ V, alt: altProp, vs: vsProp }) {
const alt = altProp != null ? altProp : num(V.altitude);
const vs = vsProp != null ? vsProp : num(V.vspeed);
const altBug = num(V.apAltBug), baro = num(V.baro, 29.92);
const x = W - 70 - 84, W2 = 84, top = 110, h = 350, cy = top + h / 2, px = 0.42;
const ticks = [];
const lo = Math.floor((alt - 420) / 100) * 100;
@@ -374,6 +569,19 @@ function AltitudeTape({ V }) {
<text x={x + W2 - 24} y={y + 7} textAnchor="end" fill="#fff" fontSize="19" fontFamily="monospace">{a}</text></g>);
}
const bugY = Math.max(top, Math.min(top + h, cy + (alt - altBug) * px));
// magenta altitude trend vector: 6-second projection from vertical speed
const trendY = Math.max(top, Math.min(top + h, cy + (alt - (alt + vs * 0.1)) * px));
// altitude alerting (GDU 1040): within 1000 ft of the selected altitude the
// box flashes cyan (approaching); within 200 ft it's captured; if it then
// deviates >200 ft the readout turns amber. capRef remembers the capture.
const capRef = useRef(false);
const dAlt = altBug > 0 ? alt - altBug : null;
const adA = dAlt == null ? Infinity : Math.abs(dAlt);
if (adA <= 200) capRef.current = true;
else if (adA > 1000) capRef.current = false;
const approaching = dAlt != null && adA <= 1000 && adA > 200 && !capRef.current;
const deviated = capRef.current && adA > 200;
const selColor = deviated ? '#ffce46' : '#0ff';
// rolling readout: leading hundreds (static) + a two-digit drum that *rolls*
// through 20-ft steps, so you always see the value you're between — exactly
// like the mechanical tens drum on the real GDU 1040.
@@ -386,11 +594,18 @@ function AltitudeTape({ V }) {
const drumX = x + W2 + 4, drumW = 26, drumCx = drumX + drumW / 2;
return (
<g fontFamily="monospace">
{/* selected altitude (cyan) above the tape */}
<rect x={x - 6} y={top - 32} width={W2 + 6} height={26} fill="#000" stroke="#0ff" strokeWidth="1.4" />
<text x={x + W2 - 6} y={top - 13} textAnchor="end" fill="#0ff" fontSize="19">{selStr}</text>
{/* selected altitude above the tape — flashes when approaching, amber on
deviation after capture (altitude alerter) */}
<g className={approaching || deviated ? 'alt-alert' : ''}>
<rect x={x - 6} y={top - 32} width={W2 + 6} height={26} fill={deviated ? '#3a2e00' : '#000'} stroke={selColor} strokeWidth={approaching || deviated ? 2.2 : 1.4} />
<text x={x + W2 - 6} y={top - 13} textAnchor="end" fill={selColor} fontSize="19">{selStr}</text>
</g>
<rect x={x} y={top} width={W2} height={h} fill="#9aa6b3" fillOpacity="0.34" />
{ticks}
{/* magenta altitude trend vector (6-sec projection) on the inner edge */}
{Math.abs(vs) > 30 && Math.abs(trendY - cy) > 2 && (
<rect x={x} y={Math.min(cy, trendY)} width="4" height={Math.abs(trendY - cy)} fill="#ff20ff" />
)}
{/* selected-altitude bug (cyan) on the tape */}
<path d={`M${x} ${bugY - 7} h7 v14 h-7 z`} fill="none" stroke="#0ff" strokeWidth="2" />
{/* current-altitude readout (points left toward the tape): static hundreds
@@ -398,9 +613,9 @@ function AltitudeTape({ V }) {
values are visible at once with the pointer between them (GDU 1040). */}
<defs><clipPath id="altdrum"><rect x={drumX} y={cy - 22} width={drumW} height={44} /></clipPath></defs>
<polygon points={`${x},${cy} ${x + 20},${cy - 24} ${drumX + drumW},${cy - 24} ${drumX + drumW},${cy + 24} ${x + 20},${cy + 24}`}
fill="#000" stroke="#fff" strokeWidth="2" />
<text x={drumX - 3} y={cy + 9} textAnchor="end" fill="#fff" fontSize="27" fontWeight="bold">{hi}</text>
<g clipPath="url(#altdrum)" fill="#fff" fontSize="20" fontWeight="bold">
fill="#000" stroke={deviated ? '#ffce46' : '#fff'} strokeWidth="2" />
<text x={drumX - 3} y={cy + 9} textAnchor="end" fill={deviated ? '#ffce46' : '#fff'} fontSize="27" fontWeight="bold">{hi}</text>
<g clipPath="url(#altdrum)" fill={deviated ? '#ffce46' : '#fff'} fontSize="20" fontWeight="bold">
{[-1, 0, 1, 2].map((k) => {
const v = base + k * STEP;
const s = String(((v % 100) + 100) % 100).padStart(2, '0');
@@ -436,14 +651,20 @@ function VSI({ x, cy, h, vs, bug }) {
}
/* ---------------- HSI compass rose ---------------- */
function HSI({ V, nav }) {
const hdg = ((num(V.heading) % 360) + 360) % 360;
function HSI({ V, nav, hdg: hdgProp }) {
const hdg = hdgProp != null ? hdgProp : ((num(V.heading) % 360) + 360) % 360;
const bug = num(V.apHdgBug);
// With an active flight-plan leg the CDI follows OUR GPS guidance (desired
// track + cross-track); otherwise it mirrors the sim's nav source.
const crs = nav ? nav.dtk : num(V.obsCrs, 360);
const def = nav ? nav.def : num(V.hsiDef);
const toFrom = nav ? 1 : num(V.hsiToFrom);
// CDI source mirrors the in-sim G1000: 2 = GPS (magenta), 0/1 = VLOC1/2 (green).
// With GPS source + an active leg the CDI follows OUR GPS guidance (desired
// track + cross-track); on VLOC it follows the sim's VOR/LOC needle.
const src = Math.round(num(V.cdiSrc, 2)); // default GPS when unknown
const isGps = src === 2;
const useNav = isGps && !!nav;
const C = isGps ? '#e040fb' : '#00d800'; // magenta GPS / green VLOC
const srcLabel = isGps ? 'GPS' : (src === 1 ? 'VLOC2' : 'VLOC1');
const crs = useNav ? nav.dtk : num(V.obsCrs, 360);
const def = useNav ? nav.def : num(V.hsiDef);
const toFrom = useNav ? 1 : num(V.hsiToFrom);
const cx = W / 2, cy = 630, r = 130;
const ticks = [];
@@ -475,30 +696,72 @@ function HSI({ V, nav }) {
<g transform={`rotate(${bugA} ${cx} ${cy})`}>
<path d={`M${cx} ${cy - r} l-10 -12 h6 v12 h8 v-12 h6 z`} fill="#0ff" stroke="#000" />
</g>
{/* GPS source label */}
<text x={cx - 56} y={cy - 10} textAnchor="middle" fill="#e040fb" fontSize="15">GPS</text>
<text x={cx + 56} y={cy - 10} textAnchor="middle" fill="#e040fb" fontSize="15">ENR</text>
{/* course pointer + CDI (magenta = GPS source) */}
{/* CDI source label (GPS magenta / VLOC green) */}
<text x={cx - 56} y={cy - 10} textAnchor="middle" fill={C} fontSize="15">{srcLabel}</text>
{isGps && <text x={cx + 56} y={cy - 10} textAnchor="middle" fill={C} fontSize="15">ENR</text>}
{/* course pointer + CDI */}
<g transform={`rotate(${crsA} ${cx} ${cy})`}>
<line x1={cx} y1={cy - r + 18} x2={cx} y2={cy - 40} stroke="#e040fb" strokeWidth="4" />
<polygon points={`${cx},${cy - r + 4} ${cx - 9},${cy - r + 22} ${cx + 9},${cy - r + 22}`} fill="#e040fb" />
<line x1={cx} y1={cy + 40} x2={cx} y2={cy + r - 18} stroke="#e040fb" strokeWidth="4" />
<line x1={cx} y1={cy - r + 18} x2={cx} y2={cy - 40} stroke={C} strokeWidth="4" />
<polygon points={`${cx},${cy - r + 4} ${cx - 9},${cy - r + 22} ${cx + 9},${cy - r + 22}`} fill={C} />
<line x1={cx} y1={cy + 40} x2={cx} y2={cy + r - 18} stroke={C} strokeWidth="4" />
{/* CDI deviation bar */}
<line x1={cx + defPx} y1={cy - 42} x2={cx + defPx} y2={cy + 42} stroke="#e040fb" strokeWidth="5" />
<line x1={cx + defPx} y1={cy - 42} x2={cx + defPx} y2={cy + 42} stroke={C} strokeWidth="5" />
{[-2, -1, 1, 2].map((d) => <circle key={d} cx={cx + d * 26} cy={cy} r={3.5} fill="none" stroke="#fff" strokeWidth="1.5" />)}
{toFrom > 0 && <polygon points={toFrom === 1
? `${cx},${cy - 60} ${cx - 9},${cy - 46} ${cx + 9},${cy - 46}`
: `${cx},${cy + 60} ${cx - 9},${cy + 46} ${cx + 9},${cy + 46}`} fill="#e040fb" />}
: `${cx},${cy + 60} ${cx - 9},${cy + 46} ${cx + 9},${cy + 46}`} fill={C} />}
</g>
{/* cyan bearing pointer to the active flight-plan waypoint (BRG) */}
{/* bearing pointers — BRG1 = NAV1 (solid single needle), BRG2 = GPS active
leg (hollow double needle). Both track the station/waypoint via the
sim's bearing datarefs, so they stay in sync with the 3-D G1000. */}
{num(V.nav1Dme) > 0 && (
<g transform={`rotate(${num(V.nav1Brg) - hdg} ${cx} ${cy})`} stroke="#0ff" fill="#0ff">
<polygon points={`${cx},${cy - r + 2} ${cx - 7},${cy - r + 20} ${cx + 7},${cy - r + 20}`} />
<line x1={cx} y1={cy - r + 20} x2={cx} y2={cy - 36} strokeWidth="3" />
<line x1={cx} y1={cy + 36} x2={cx} y2={cy + r - 6} strokeWidth="3" />
</g>
)}
{nav && (
<g transform={`rotate(${nav.brg - hdg} ${cx} ${cy})`}>
<line x1={cx} y1={cy - r + 2} x2={cx} y2={cy - r + 30} stroke="#0ff" strokeWidth="3" />
<polygon points={`${cx},${cy - r - 6} ${cx - 8},${cy - r + 12} ${cx + 8},${cy - r + 12}`} fill="none" stroke="#0ff" strokeWidth="2.5" />
<line x1={cx} y1={cy + r - 30} x2={cx} y2={cy + r - 2} stroke="#0ff" strokeWidth="3" />
<g transform={`rotate(${nav.brg - hdg} ${cx} ${cy})`} stroke="#0ff" fill="none" strokeWidth="2.5">
<polygon points={`${cx},${cy - r + 2} ${cx - 8},${cy - r + 22} ${cx + 8},${cy - r + 22}`} />
<line x1={cx - 3} y1={cy - r + 22} x2={cx - 3} y2={cy - 36} />
<line x1={cx + 3} y1={cy - r + 22} x2={cx + 3} y2={cy - 36} />
<line x1={cx - 3} y1={cy + 36} x2={cx - 3} y2={cy + r - 6} />
<line x1={cx + 3} y1={cy + 36} x2={cx + 3} y2={cy + r - 6} />
</g>
)}
<rect x={cx - 7} y={cy - 7} width={14} height={14} fill="#ffcc00" stroke="#000" strokeWidth="2" />
{/* BRG info windows (lower corners): source + DME distance */}
<BrgWindow x={150} y={cy + 36} n={1} src="NAV1" dist={num(V.nav1Dme)} solid />
<BrgWindow x={W - 150} y={cy + 36} n={2} src="GPS" dist={nav ? nav.dist : 0} anchor="end" />
</g>
);
}
// One BRG pointer info window (icon + source + DME distance), like the G1000's
// lower-corner bearing readouts.
function BrgWindow({ x, y, n, src, dist, solid = false, anchor = 'start' }) {
if (!(dist > 0) && src !== 'GPS') return null;
return (
<g fontFamily="monospace" textAnchor={anchor}>
<text x={x} y={y} fill="#0ff" fontSize="13">BRG{n}</text>
<text x={x} y={y + 19} fill="#fff" fontSize="15">{src}</text>
<text x={x} y={y + 38} fill="#0ff" fontSize="15">{dist > 0 ? `${dist.toFixed(1)}NM` : ' '}</text>
</g>
);
}
// Marker-beacon annunciator (OM cyan / MM amber / IM white), upper-left.
function Marker({ V }) {
const im = num(V.mkrInner), mm = num(V.mkrMiddle), om = num(V.mkrOuter);
const m = im > 0 ? { t: 'IM', c: '#000', bg: '#fff' }
: mm > 0 ? { t: 'MM', c: '#000', bg: '#e0a000' }
: om > 0 ? { t: 'OM', c: '#fff', bg: '#19d3ff' } : null;
if (!m) return null;
return (
<g>
<rect x={156} y={118} width={42} height={26} rx={13} fill={m.bg} stroke="#000" strokeWidth="1.5" />
<text x={177} y={137} textAnchor="middle" fill={m.c} fontSize="16" fontWeight="bold" fontFamily="monospace">{m.t}</text>
</g>
);
}
@@ -579,6 +842,35 @@ function HdgCrsBoxes({ V, nav }) {
);
}
/* ---------------- wind box (lower-left), like the real G1000 ---------------- */
function Wind({ V }) {
const spd = Math.round(num(V.windSpd));
const dir = ((Math.round(num(V.windDir)) % 360) + 360) % 360;
const hdg = num(V.heading);
const bx = 14, by = 636, bw = 128, bh = 92, cxw = bx + 30, cyw = by + 52;
// arrow points the way the wind blows relative to the nose (from dir → +180)
const rot = dir + 180 - hdg;
return (
<g fontFamily="monospace">
<rect x={bx} y={by} width={bw} height={bh} rx="4" fill="#000a" stroke="#3a3a3a" />
<text x={bx + bw / 2} y={by + 17} textAnchor="middle" fill="#9aa" fontSize="12">WIND</text>
{spd >= 1 ? (
<>
<circle cx={cxw} cy={cyw} r="18" fill="none" stroke="#5a5f66" strokeWidth="1.5" />
<g transform={`rotate(${rot} ${cxw} ${cyw})`} stroke="#fff" strokeWidth="3" fill="#fff">
<line x1={cxw} y1={cyw + 16} x2={cxw} y2={cyw - 14} />
<polygon points={`${cxw},${cyw - 20} ${cxw - 6},${cyw - 8} ${cxw + 6},${cyw - 8}`} stroke="none" />
</g>
<text x={bx + 58} y={by + 46} fill="#fff" fontSize="20">{String(dir).padStart(3, '0')}°</text>
<text x={bx + 58} y={by + 72} fill="#fff" fontSize="20">{spd}<tspan fill="#9aa" fontSize="13">KT</tspan></text>
</>
) : (
<text x={bx + bw / 2} y={by + 56} textAnchor="middle" fill="#6f808d" fontSize="14">NO WIND</text>
)}
</g>
);
}
/* ---------------- bottom data line: OAT / ISA / XPDR / LCL ---------------- */
function DataStrip({ V }) {
const oatC = num(V.oat);
+30 -9
View File
@@ -21,6 +21,7 @@ export default function Proc({ xp, onClose }) {
const [procs, setProcs] = useState(null);
const [err, setErr] = useState('');
const [cat, setCat] = useState('approach');
const [view, setView] = useState('menu'); // 'menu' (PDF action list) | 'pick'
const [selProc, setSelProc] = useState(null); // { name, transitions }
const [selTrans, setSelTrans] = useState('');
const [legs, setLegs] = useState([]);
@@ -59,12 +60,39 @@ export default function Proc({ xp, onClose }) {
onClose();
};
const catLabel = CATS.find((c) => c.id === cat).label;
// The PDF's action menu. SELECT … opens our picker for that category;
// ACTIVATE … are shown for authenticity (armed-procedure actions).
if (view === 'menu') {
const item = (label, onClick, sel) => (
<button className={`proc-menu-i ${sel ? 'sel' : ''}`} onClick={onClick}>{label}</button>
);
const sel = (c) => { setCat(c); setSelProc(null); setSelTrans(''); setView('pick'); };
return (
<div className="gwin-backdrop" onClick={onClose}>
<div className="dlg proc menu" onClick={(e) => e.stopPropagation()}>
<div className="dlg-head">PROCEDURES</div>
<div className="proc-menu">
{item('ACTIVATE VECTOR-TO-FINAL', () => {})}
{item('ACTIVATE APPROACH', () => {})}
{item('ACTIVATE MISSED APPROACH', () => {})}
{item('SELECT APPROACH', () => sel('approach'), true)}
{item('SELECT ARRIVAL', () => sel('arrival'))}
{item('SELECT DEPARTURE', () => sel('departure'))}
</div>
</div>
</div>
);
}
return (
<div className="dlg-backdrop" onClick={onClose}>
<div className="gwin-backdrop" onClick={onClose}>
<div className="dlg proc" onClick={(e) => e.stopPropagation()}>
<div className="dlg-head">PROCEDURES</div>
<div className="dlg-head">{catLabel}</div>
<div className="proc-body">
<div className="proc-apt">
<button className="proc-back" onClick={() => setView('menu')}></button>
<label>APT</label>
<input value={query} onChange={(e) => setQuery(e.target.value.toUpperCase())}
onKeyDown={(e) => e.key === 'Enter' && setIcao(query)}
@@ -73,13 +101,6 @@ export default function Proc({ xp, onClose }) {
</div>
{err && <div className="proc-err">{err}</div>}
<div className="proc-tabs">
{CATS.map((c) => (
<button key={c.id} className={cat === c.id ? 'on' : ''}
onClick={() => { setCat(c.id); setSelProc(null); setSelTrans(''); }}>{c.label}</button>
))}
</div>
<div className="proc-cols">
<div className="proc-list">
<div className="proc-coltitle">{procs ? `${catList.length}` : '—'} PROC</div>
+48
View File
@@ -0,0 +1,48 @@
import React from 'react';
import { num } from '../api/useXplane.js';
// Touch-friendly radio tuner, styled like our KAP 140 (green LCD) + the desktop
// launcher (macOS-dark chrome). Tunes the STANDBY frequency and swaps it active
// via X-Plane's own per-radio commands (no unit-sensitive frequency writes).
const fmt = (v, isCom) => (num(v) / 100).toFixed(isCom ? 3 : 2);
export default function RadioTuner({ values, command, radio, onClose }) {
const { id, label, isCom } = radio;
const sb = values[`${id}Sb`];
const act = values[id];
const cmd = (s) => command(`${id}${s}`);
return (
<div className="dlg-backdrop" onClick={onClose}>
<div className="rtuner" onClick={(e) => e.stopPropagation()}>
<div className="rt-head">
<span className="rt-title">{label}</span>
<span className="rt-kind">{isCom ? 'COM' : 'NAV'}</span>
</div>
<div className="rt-lcd">
<div className="rt-f"><span>ACTIVE</span><b className="act">{fmt(act, isCom)}</b></div>
<button className="rt-swap" onClick={() => cmd('Swap')} title="Aktiv ↔ Standby"></button>
<div className="rt-f right"><span>STANDBY</span><b className="sby">{fmt(sb, isCom)}</b></div>
</div>
<div className="rt-tune">
<div className="rt-row">
<span className="rt-unit">MHz</span>
<button className="rt-step" onClick={() => cmd('CoarseDown')}></button>
<button className="rt-step" onClick={() => cmd('CoarseUp')}>+</button>
</div>
<div className="rt-row">
<span className="rt-unit">kHz</span>
<button className="rt-step" onClick={() => cmd('FineDown')}></button>
<button className="rt-step" onClick={() => cmd('FineUp')}>+</button>
</div>
</div>
<div className="rt-actions">
<button className="rt-btn primary" onClick={() => cmd('Swap')}> Auf Aktiv</button>
<button className="rt-btn" onClick={onClose}>Schließen</button>
</div>
</div>
</div>
);
}
-1
View File
@@ -46,7 +46,6 @@ export default function TimerRef({ values, onClose }) {
<div className="tmr-window">
<div className="nrst-head">
<span className="nrst-title">TIMER / REFERENCES</span>
{onClose && <button className="nrst-x" onClick={onClose}></button>}
</div>
<div className="tmr-body">
<div className="tmr-clock">{fmt(shown)}</div>
+214 -36
View File
@@ -6,15 +6,17 @@
/* App chrome (everything that is NOT a G1000 instrument): same clean
macOS-dark look as the desktop launcher. */
--ui-font: 'Inter', -apple-system, BlinkMacSystemFont, 'SF Pro Text', 'Segoe UI', system-ui, sans-serif;
--c-bg: #1c1c1e;
--c-surface: #2c2c2e;
--c-fill: #3a3a3c;
--c-line: #48484a;
--c-line-soft: #38383a;
--c-txt: #ffffff;
--c-txt2: #ebebf5;
--c-mut: #8e8e93;
--c-green: #30d158;
/* monochrome chrome palette (191919 / 0f0f0f / f0f0f0) — no colour accent */
--c-bg: #0f0f0f;
--c-surface: #191919;
--c-fill: #232323;
--c-line: #3a3a3a;
--c-line-soft: #2a2a2a;
--c-txt: #f0f0f0;
--c-txt2: #e0e0e0;
--c-mut: #8a8a8a;
--c-on: #f0f0f0; /* active/primary highlight (inverted) */
--c-green: #30d158; /* kept only for the green LCD/display look */
--c-amber: #ffd60a;
--c-red: #ff453a;
color-scheme: dark;
@@ -54,7 +56,7 @@ body {
}
.sb-top:hover { background: #34343a; }
.brand { font-weight: 700; font-size: 17px; letter-spacing: .3px; white-space: nowrap; }
.brand span { color: var(--c-green); font-weight: 500; }
.brand span { color: var(--c-mut); font-weight: 500; }
.sb-chev { color: var(--c-mut); font-size: 12px; }
.app.nav-narrow .brand span { display: none; }
.app.nav-narrow .sb-chev { display: none; }
@@ -70,7 +72,7 @@ body {
transition: background .12s, color .12s;
}
.snav-i:hover { background: var(--c-surface); color: var(--c-txt2); }
.snav-i.active { background: rgba(48,209,88,.16); color: var(--c-green); border-color: rgba(48,209,88,.35); }
.snav-i.active { background: rgba(255,255,255,.09); color: var(--c-txt); border-color: #444; }
.snav-ic { flex: 0 0 22px; }
.snav-lbl { white-space: nowrap; }
.app.nav-narrow .snav-lbl { display: none; }
@@ -157,16 +159,35 @@ body {
.pfd-inset .mapwrap, .pfd-inset .leaflet-host { width: 100%; height: 100%; }
.mapwrap.inset .leaflet-control-container { display: none; }
/* NRST (nearest) window — pops over the right side of the PFD */
/* G1000 windows: flat opaque rectangles embedded in the display — no shadow,
no rounded corners, thin light border. They look part of the screen. */
.nrst-window {
position: absolute; z-index: 4; top: 9%; right: 1.5%; width: 41%; max-width: 440px;
background: rgba(8, 10, 12, 0.94); border: 1px solid #4a5560; border-radius: 3px;
color: #fff; font-family: 'Roboto Mono', monospace; box-shadow: 0 4px 18px rgba(0,0,0,0.6);
position: absolute; z-index: 4; right: 2%; top: 50%; bottom: 11%; width: 31%; max-width: 320px;
display: flex; flex-direction: column;
background: #05080b; border: 1px solid #7e8a94; border-radius: 0;
color: #fff; font-family: 'Roboto Mono', monospace;
}
.nrst-list { flex: 1; }
/* NEAREST AIRPORTS: two-line entries like the real GDU (ident/brg/dis/approach
then com-type/freq/runway-length) */
.apt-entry { padding: 4px 8px 5px; border-bottom: 1px solid #161b20; }
.apt-l1 { display: grid; grid-template-columns: 1fr auto auto auto; align-items: baseline; column-gap: 8px; }
.apt-l2 { display: grid; grid-template-columns: auto 1fr auto auto; align-items: baseline; column-gap: 6px; margin-top: 1px; }
.apt-id { color: #36d2ff; font-size: 16px; font-weight: bold; justify-self: start; }
.apt-id.cur { background: #19b8e6; color: #042230; padding: 0 4px; border-radius: 1px; }
.apt-brg, .apt-dis { color: #fff; font-size: 14px; text-align: right; }
.apt-dis u { color: #6f808d; font-size: 9px; text-decoration: none; margin-left: 1px; }
.apt-app { color: #fff; font-size: 12px; min-width: 30px; text-align: right; }
.apt-app.ils { color: #16d24a; }
.apt-comlbl { color: #6f808d; font-size: 11px; }
.apt-com { color: #fff; font-size: 13px; }
.apt-rwlbl { color: #6f808d; font-size: 11px; }
.apt-rw { color: #fff; font-size: 13px; text-align: right; }
.nrst-head { display: flex; align-items: center; gap: 8px; padding: 5px 8px; background: #11161b; border-bottom: 1px solid #2c343c; }
.nrst-title { color: #39d3c0; font-size: 13px; font-weight: bold; letter-spacing: 1px; }
.nrst-title { color: #36d2ff; font-size: 13px; font-weight: bold; letter-spacing: 2px; }
.nrst-tabs { display: flex; gap: 3px; margin-left: auto; }
.nrst-tabs button { background: #1c242c; color: #9fb0bd; border: 1px solid #2c343c; border-radius: 2px; font: inherit; font-size: 11px; padding: 2px 9px; cursor: pointer; }
.nrst-tabs button.on { background: #0c9; color: #04201c; border-color: #0c9; font-weight: bold; }
.nrst-tabs button.on { background: #19b8e6; color: #042230; border-color: #19b8e6; font-weight: bold; }
.nrst-x { background: none; border: none; color: #9fb0bd; cursor: pointer; font-size: 14px; padding: 0 2px; }
.nrst-cols, .nrst-row { display: grid; grid-template-columns: 1.3fr 0.8fr 1fr 1.1fr; align-items: baseline; padding: 2px 8px; column-gap: 4px; }
.nrst-cols { color: #6f808d; font-size: 10px; border-bottom: 1px solid #222; padding-bottom: 4px; }
@@ -177,15 +198,99 @@ body {
.nrst-row .c-xtra { color: #39d3c0; text-align: right; }
.nrst-row .c-name { grid-column: 1 / -1; color: #8b9aa6; font-size: 10px; margin-top: -1px; }
.nrst-list { max-height: 62vh; overflow-y: auto; }
/* DME + ALERTS popups (PFD DME / CAUTION softkeys) — left side, G1000 style */
.pfd-pop {
position: absolute; z-index: 4; top: 13%; left: 1.5%; width: 30%; max-width: 270px;
background: #05080b; border: 1px solid #7e8a94; border-radius: 0;
color: #fff; font-family: 'Roboto Mono', monospace;
}
.pop-grid { display: grid; grid-template-columns: auto 1fr; gap: 4px 12px; padding: 8px 12px; font-size: 15px; }
.pop-grid b { color: #6f808d; font-weight: normal; }
.pop-grid span { color: #fff; text-align: right; }
.pop-tabs { display: flex; gap: 3px; padding: 6px 8px; border-top: 1px solid #2c343c; }
.pop-tabs button { flex: 1; background: #1c242c; color: #9fb0bd; border: 1px solid #2c343c; border-radius: 2px; font: inherit; font-size: 11px; padding: 3px 0; cursor: pointer; }
.pop-tabs button.on { background: #0c9; color: #04201c; border-color: #0c9; font-weight: bold; }
/* airway name labels on the MFD map */
.awy-divicon { background: none; border: none; }
.awy-lbl { color: #8fd0f0; font: 10px 'Roboto Mono', monospace; background: rgba(0,0,0,0.45); padding: 0 2px; border-radius: 2px; white-space: nowrap; }
.pfd-pop.alerts { top: 46%; } /* below the DME window so both can be open */
/* altitude alerter: flash the selected-altitude box (approaching / deviation) */
.alt-alert { animation: altflash 1s steps(1, end) infinite; }
@keyframes altflash { 50% { opacity: 0.25; } }
.alerts-list { padding: 6px 0; max-height: 40vh; overflow-y: auto; }
.alert-row { padding: 4px 12px; font-size: 14px; border-bottom: 1px solid #161b20; }
.alert-row.warn { color: #ff5a4d; font-weight: bold; }
.alert-row.cau { color: #ffce46; }
.alert-none { padding: 10px 12px; color: #6f808d; font-size: 13px; }
/* full-area NRST (MFD page) */
.nrst-window.full { position: absolute; inset: 0; width: auto; max-width: none; border: none; border-radius: 0;
background: #0a0d10; box-shadow: none; z-index: 660; display: flex; flex-direction: column; }
.nrst-window.full .nrst-list { max-height: none; flex: 1; }
/* FLIGHT PLAN page (MFD full / PFD window) */
.fpl.full { position: absolute; inset: 0; z-index: 660; background: #0a0d10; display: flex; flex-direction: column; }
.fpl.win { width: 320px; max-height: 44%; background: #05080b; border: 1px solid #7e8a94; border-radius: 0;
display: flex; flex-direction: column; font-size: 13px; }
.fpl-head { display: flex; align-items: center; gap: 8px; padding: 9px 12px; background: #0a0f14; border-bottom: 1px solid #2c343c;
color: #36d2ff; font-family: var(--ui-font); font-weight: 700; font-size: 13px; letter-spacing: 2px; }
.fpl-tot { margin-left: auto; color: #fff; font-size: 12px; font-family: 'Saira Condensed', monospace; }
.fpl-x { background: none; border: none; color: #9fb0bd; cursor: pointer; font-size: 15px; padding: 0 2px; }
.fpl-cols, .fpl-row { display: grid; grid-template-columns: 1.5fr .8fr .8fr .8fr .9fr 30px; align-items: center; gap: 6px; padding: 4px 12px; }
.fpl-cols { color: #6f808d; font-size: 10px; border-bottom: 1px solid #222; letter-spacing: .5px; }
.fpl-cols span:nth-child(n+2) { text-align: right; }
.fpl-rows { flex: 1; overflow-y: auto; }
.fpl-row { font-size: 16px; border-bottom: 1px solid #161b20; cursor: pointer; font-family: 'Saira Condensed', monospace; }
.fpl-row:hover { background: #11161b; }
.fpl-row.act { background: rgba(255,32,255,.12); }
.fpl-row.sel { box-shadow: inset 0 0 0 1px #0ff; }
.r-wpt { color: #0ff; font-weight: 700; } .r-wpt i { color: #0a8; font-style: normal; font-size: 10px; margin-left: 6px; }
.r-wpt b { font-weight: 700; } .r-wpt b.cur { background: #19b8e6; color: #042230; padding: 0 4px; border-radius: 1px; }
.r-dtk, .r-dis, .r-cum { color: #e7edf2; text-align: right; }
.r-alt { color: #0ff; text-align: right; }
/* ORIG / DEST subtitle (PFD window) */
.fpl-od { color: #36d2ff; text-align: center; font-family: 'Roboto Mono', monospace; font-size: 14px; padding: 3px 0; border-bottom: 1px solid #1c242c; letter-spacing: 1px; }
/* compact window: DTK/DIS only (drop CUM/ALT), no editor — like the real FPL window */
.fpl.win .fpl-cols span:nth-child(4), .fpl.win .fpl-cols span:nth-child(5),
.fpl.win .r-cum, .fpl.win .r-alt, .fpl.win .r-del, .fpl.win .fpl-entry { display: none; }
.fpl.win .fpl-cols, .fpl.win .fpl-row { grid-template-columns: 1.4fr .8fr 1fr; }
.fpl.win .fpl-row { font-size: 15px; }
.fpl-row.act .r-wpt, .fpl-row.act .r-dtk, .fpl-row.act .r-dis, .fpl-row.act .r-cum, .fpl-row.act .r-alt { color: #ff5bff; }
.r-del { background: none; border: none; color: #c44; font-size: 15px; cursor: pointer; }
.fpl-empty { color: #6f808d; text-align: center; padding: 18px; font-size: 13px; font-family: var(--ui-font); }
.fpl-entry { border-top: 1px solid #2c343c; padding: 10px 12px; background: #0c1116; font-family: var(--ui-font); }
.fpl-hits { display: flex; flex-direction: column; gap: 3px; margin-bottom: 8px; }
.fpl-hits button { display: flex; align-items: baseline; gap: 8px; background: #141a20; border: 1px solid #222b33; color: #cfd6dd; font: inherit; padding: 5px 8px; cursor: pointer; text-align: left; }
.fpl-hits b { color: #0ff; } .fpl-hits i { color: #0a8; font-style: normal; font-size: 11px; } .fpl-hits span { color: #6f808d; font-size: 11px; margin-left: auto; }
.fpl-inrow { display: flex; gap: 8px; }
.fpl-inrow input { flex: 1; background: #05080b; border: 1px solid #2c343c; color: #0ff; font: inherit; font-size: 15px; letter-spacing: 1px; padding: 9px 10px; text-transform: uppercase; }
.fpl-actions { display: flex; gap: 8px; margin-top: 8px; }
.fpl-btn { flex: 1; background: #232323; color: #e0e0e0; border: 1px solid #3a3a3a; border-radius: 8px; padding: 9px; font: inherit; font-weight: 700; font-size: 13px; cursor: pointer; }
.fpl-btn.add { background: #f0f0f0; color: #0f0f0f; border-color: transparent; flex: 0 0 auto; padding: 9px 16px; }
.fpl-btn:hover:not(:disabled) { filter: brightness(1.15); } .fpl-btn:disabled { opacity: .4; cursor: default; }
.fpl-msg { margin-top: 6px; font-size: 12px; } .fpl-msg.ok { color: #39d3c0; } .fpl-msg.err { color: #ffae42; }
/* saved-plans load picker */
.fpl-load { position: absolute; inset: 0; z-index: 10; background: #000a; display: flex; align-items: center; justify-content: center; }
.fpl-load-box { width: min(380px, 90%); max-height: 80%; background: #0c1116; border: 1px solid #2c343c; border-radius: 10px; display: flex; flex-direction: column; box-shadow: 0 12px 36px rgba(0,0,0,.6); }
.fpl-load-head { display: flex; align-items: center; justify-content: space-between; padding: 9px 12px; background: #11161b; border-bottom: 1px solid #2c343c; color: #39d3c0; font-family: var(--ui-font); font-weight: 700; font-size: 13px; }
.fpl-load-head button { background: none; border: none; color: #9fb0bd; cursor: pointer; font-size: 14px; }
.fpl-load-list { overflow-y: auto; padding: 6px; display: flex; flex-direction: column; gap: 4px; }
.fpl-load-list button { background: #141a20; border: 1px solid #222b33; color: #0ff; font-family: 'Saira Condensed', monospace; font-size: 16px; font-weight: 700; text-align: left; padding: 9px 12px; border-radius: 6px; cursor: pointer; }
.fpl-load-list button:hover { background: #1c252e; }
.fpl-load-list button i { color: #6f808d; font-style: normal; font-size: 11px; margin-left: 6px; }
/* page-group indicator (bottom-right), like the real G1000; tap = next page */
.mfd-pageind { position: absolute; right: 6px; bottom: 6px; z-index: 700; display: flex; align-items: center; gap: 6px;
background: #000a; border: 1px solid #2c343c; border-radius: 4px; padding: 5px 8px; cursor: pointer;
font: 700 12px/1 monospace; color: #0ff; }
.mfd-pageind em { width: 8px; height: 8px; border: 1px solid #0ff; display: inline-block; }
.mfd-pageind em.on { background: #0ff; }
.nrst-empty { color: #6f808d; text-align: center; padding: 12px; font-size: 12px; }
/* XPDR squawk-entry readout above the softkey keypad */
.squawk-entry { text-align: center; color: #9fb0bd; font-family: 'Roboto Mono', monospace; font-size: 13px; letter-spacing: 1px; padding: 3px 0; }
.squawk-entry b { color: #19ff19; font-size: 18px; letter-spacing: 5px; margin-left: 6px; }
/* TMR/REF window — left side of the PFD */
.tmr-window {
position: absolute; z-index: 4; top: 9%; left: 1.5%; width: 30%; max-width: 320px;
background: rgba(8, 10, 12, 0.94); border: 1px solid #4a5560; border-radius: 3px;
color: #fff; font-family: 'Roboto Mono', monospace; box-shadow: 0 4px 18px rgba(0,0,0,0.6);
position: absolute; z-index: 4; bottom: 11%; right: 2%; width: 31%; max-width: 320px;
background: #05080b; border: 1px solid #7e8a94; border-radius: 0;
color: #fff; font-family: 'Roboto Mono', monospace;
}
.tmr-body { padding: 8px 10px; }
.tmr-clock { font-size: 34px; font-weight: bold; text-align: center; color: #fff; letter-spacing: 2px; }
@@ -210,24 +315,35 @@ body {
.tmr-minalert { margin-top: 6px; text-align: center; background: #ffd24a; color: #1a1400; font-weight: bold; padding: 3px; border-radius: 2px; letter-spacing: 2px; }
/* Modal dialogs (Direct-To, …) */
.dlg-backdrop { position: fixed; inset: 0; z-index: 20; background: rgba(0,0,0,0.55); display: flex; align-items: center; justify-content: center; }
.dlg { background: #0c1015; border: 1px solid #3a4651; border-radius: 5px; min-width: 320px; color: #fff; font-family: 'Roboto Mono', monospace; box-shadow: 0 8px 30px rgba(0,0,0,0.7); }
.dlg-head { background: #11161b; padding: 8px 12px; border-bottom: 1px solid #2c343c; color: #fff; font-weight: bold; letter-spacing: 1px; border-radius: 5px 5px 0 0; }
/* G1000 side-window dialogs (PROC / Direct-To / FPL): compact panels in the
display's lower-right, no screen dimming — like the real unit. */
.gwin-backdrop { position: absolute; inset: 0; z-index: 20; background: transparent; display: flex; align-items: flex-end; justify-content: flex-end; padding: 0 2% 11% 0; }
.dlg { background: #05080b; border: 1px solid #7e8a94; border-radius: 0; min-width: 280px; color: #fff; font-family: 'Roboto Mono', monospace; }
.dlg-head { background: #0a0f14; padding: 6px 12px; border-bottom: 1px solid #2c343c; color: #36d2ff; font-weight: bold; letter-spacing: 2px; text-align: center; border-radius: 2px 2px 0 0; }
.dto-arrow { color: #e040fb; margin-right: 8px; }
.dto-body { padding: 12px; }
.dto-lbl { color: #6f808d; font-size: 11px; display: block; margin-bottom: 4px; }
.dto-input { width: 100%; box-sizing: border-box; background: #05080b; border: 1px solid #2c343c; color: #0ff; font: inherit; font-size: 20px; letter-spacing: 3px; padding: 6px 10px; text-transform: uppercase; }
.dto-hits { display: flex; flex-direction: column; gap: 3px; margin-top: 6px; }
.dto-hits button { display: flex; align-items: baseline; gap: 8px; background: #141a20; border: 1px solid #222b33; color: #cfd6dd; font: inherit; padding: 5px 8px; cursor: pointer; text-align: left; }
.dto-hits button.on { border-color: #0c9; background: #0a1f1b; }
.dto-hits button.on { border-color: #36d2ff; background: #0d2c38; }
.dto-hits button b { color: #0ff; } .dto-hits button i { color: #0a8; font-style: normal; font-size: 11px; } .dto-hits button span { color: #6f808d; font-size: 11px; margin-left: auto; }
.dto-sel { display: flex; align-items: baseline; gap: 10px; margin-top: 10px; padding-top: 8px; border-top: 1px solid #222; }
.dto-sel .dto-id { color: #0ff; font-size: 20px; font-weight: bold; }
.dto-sel .dto-type { color: #0a8; font-size: 11px; }
.dto-sel .dto-vec { color: #e040fb; margin-left: auto; font-weight: bold; }
.dto-tgt { display: flex; align-items: baseline; gap: 10px; margin-top: 10px; }
.dto-tgt .dto-id { color: #36d2ff; font-size: 22px; font-weight: bold; letter-spacing: 1px; }
.dto-tgt .dto-type { color: #6f808d; font-size: 11px; }
.dto-grid { display: grid; grid-template-columns: auto 1fr auto 1fr; align-items: baseline; gap: 6px 8px; margin-top: 10px; padding-top: 8px; border-top: 1px solid #222; }
.dto-grid b { color: #6f808d; font-weight: normal; font-size: 12px; }
.dto-grid span { color: #fff; font-size: 15px; }
.dlg-actions { display: flex; gap: 8px; padding: 10px 12px; border-top: 1px solid #2c343c; }
.dlg-actions .fbtn { flex: 1; }
/* PROC dialog */
.dlg.proc { width: 640px; max-width: 92vw; }
.dlg.proc { width: 400px; max-width: 38%; }
.dlg.proc.menu { width: 300px; }
.proc-menu { display: flex; flex-direction: column; padding: 4px 0; }
.proc-menu-i { background: none; border: none; border-bottom: 1px solid #11161b; color: #d7e2ea; font: inherit; font-family: 'Roboto Mono', monospace; font-size: 14px; text-align: left; padding: 8px 12px; cursor: pointer; letter-spacing: .5px; }
.proc-menu-i:hover { background: #11161b; }
.proc-menu-i.sel { background: #19b8e6; color: #042230; font-weight: bold; }
.proc-back { background: #1c242c; border: 1px solid #2c343c; color: #36d2ff; font: inherit; font-size: 14px; line-height: 1; padding: 4px 9px; cursor: pointer; border-radius: 2px; }
.proc-body { padding: 12px; }
.proc-apt { display: flex; align-items: center; gap: 8px; }
.proc-apt label { color: #6f808d; font-size: 11px; }
@@ -236,13 +352,13 @@ body {
.proc-err { color: #ffae42; font-size: 12px; margin-top: 6px; }
.proc-tabs { display: flex; gap: 4px; margin: 10px 0 6px; }
.proc-tabs button { flex: 1; background: #1c242c; color: #9fb0bd; border: 1px solid #2c343c; font: inherit; font-size: 12px; padding: 5px; cursor: pointer; }
.proc-tabs button.on { background: #0c9; color: #04201c; font-weight: bold; border-color: #0c9; }
.proc-cols { display: grid; grid-template-columns: 1fr 1fr 1.4fr; gap: 6px; height: 300px; }
.proc-tabs button.on { background: #19b8e6; color: #042230; font-weight: bold; border-color: #19b8e6; }
.proc-cols { display: grid; grid-template-columns: 1fr 1fr 1.3fr; gap: 5px; height: 220px; }
.proc-list, .proc-preview { background: #05080b; border: 1px solid #1c242c; overflow-y: auto; display: flex; flex-direction: column; }
.proc-coltitle { position: sticky; top: 0; background: #11161b; color: #6f808d; font-size: 10px; padding: 4px 8px; border-bottom: 1px solid #222; }
.proc-list button { background: none; border: none; border-bottom: 1px solid #11161b; color: #cfd6dd; font: inherit; font-size: 14px; text-align: left; padding: 6px 8px; cursor: pointer; }
.proc-list button:hover { background: #11161b; }
.proc-list button.on { background: #0a1f1b; color: #0ff; font-weight: bold; }
.proc-list button.on { background: #19b8e6; color: #042230; font-weight: bold; }
.proc-empty { color: #6f808d; font-size: 11px; padding: 8px; }
.proc-leg { display: flex; align-items: baseline; gap: 8px; padding: 5px 8px; border-bottom: 1px solid #11161b; font-size: 13px; }
.proc-leg b { color: #0ff; } .proc-leg u { color: #39d3c0; font-size: 10px; text-decoration: none; margin-left: auto; }
@@ -258,7 +374,7 @@ body {
/* ---- GDU-1040 bezel ---- */
.bezel {
width: 100%; height: 100%; display: flex; align-items: stretch; gap: 0;
width: 100%; height: 100%; display: flex; align-items: stretch; gap: 12px;
background: linear-gradient(150deg, #3a3c40, #202123 55%, #2c2d30);
border-radius: 18px; padding: 12px; box-shadow: inset 0 1px 0 #4a4c50, 0 8px 30px #000;
font-family: 'Saira Semi Condensed', sans-serif;
@@ -267,9 +383,18 @@ body {
.bezel-title { text-align: center; color: #c9ced3; font-size: 14px; font-weight: 700; letter-spacing: 3px; padding: 2px 0 6px; }
.bezel-screen {
flex: 1; background: #000; overflow: hidden; position: relative;
display: flex; min-height: 0;
display: flex; flex-direction: column; min-height: 0;
}
.bezel-screen > * { width: 100%; height: 100%; }
.screen-content { flex: 1; min-height: 0; width: 100%; position: relative; }
.screen-content > * { width: 100%; height: 100%; }
/* on-screen softkey label row (lowest line of the display) */
.sk-labels { flex: 0 0 auto; display: grid; grid-template-columns: repeat(12, 1fr); gap: 4px;
padding: 3px 2px; background: #000; border-top: 1px solid #1c1c1c; }
.skl { display: flex; align-items: center; justify-content: center; height: 20px; border-radius: 3px;
color: #e8edf2; font-size: 11px; font-weight: 700; letter-spacing: .3px; font-family: 'Saira Semi Condensed', sans-serif; }
.skl.empty { color: transparent; }
.skl.on { background: #e8edf2; color: #0a0c0e; }
.skl.caution { background: linear-gradient(#ffd23a, #e0b400); color: #1a1b1e; }
.softkeys { display: grid; grid-template-columns: repeat(12, 1fr); gap: 4px; padding: 4px 2px 1px; }
.softkey {
height: 20px; display: flex; align-items: center; justify-content: center;
@@ -283,12 +408,19 @@ body {
.softkey.on { background: #e8edf2; color: #0a0c0e; border-top-color: #fff; font-weight: 800; }
.softkey.caution { color: #1a1b1e; background: linear-gradient(#ffd23a, #e0b400); border-top-color: #fff2a8; font-weight: 800; }
.bezel-knobs { display: flex; flex-direction: column; align-items: center; justify-content: space-around; padding: 4px 6px; gap: 6px; }
.bezel-knobs.left { width: 88px; } .bezel-knobs.right { width: 100px; }
.bezel-knobs { display: flex; flex-direction: column; align-items: center; padding: 12px 6px; gap: 14px; flex: 0 0 104px; width: 104px; }
/* NAV/HDG (+ AP block on MFD) group at the top, ALT pinned to the bottom */
.bezel-knobs.left { justify-content: flex-start; }
.bezel-knobs.left > .knob-wrap:last-child { margin-top: auto; }
/* COM at top … FMS at bottom, evenly spread */
.bezel-knobs.right { justify-content: space-between; }
.knob-wrap { display: flex; flex-direction: column; align-items: center; gap: 2px; position: relative; }
.knob-lbl { color: #d2d7dc; font-size: 12px; font-weight: 800; letter-spacing: 1px; }
.knob-sub { color: #8b9197; font-size: 8.5px; font-weight: 600; letter-spacing: .3px; text-align: center; }
.knob-extra { position: absolute; right: -10px; top: 6px; width: 20px; height: 16px; background: #1a1b1e; border: 1px solid #000; border-radius: 3px; color: #cfd6dc; font-size: 11px; text-align: center; line-height: 16px; }
.knob-swap { position: absolute; right: 2px; top: 0; width: 26px; height: 20px; background: linear-gradient(#2a2c2f, #16171a); border: 1px solid #000; border-top: 1px solid #45474b; border-radius: 4px; color: #0ff; font-size: 13px; cursor: pointer; padding: 0; line-height: 1; }
.knob-swap:active { background: #000; }
.knob-emerg { position: absolute; left: 2px; top: 2px; color: #c33; font-size: 8px; font-weight: 700; letter-spacing: .3px; }
.knob.outer {
width: 60px; height: 60px; border-radius: 50%; display: flex; align-items: center; justify-content: center; position: relative;
background: radial-gradient(circle at 35% 30%, #55585d, #2a2c2f 70%); box-shadow: 0 2px 5px #000, inset 0 1px 0 #6a6d72;
@@ -296,6 +428,13 @@ body {
.knob-wrap.big .knob.outer { width: 68px; height: 68px; }
.knob.inner { width: 26px; height: 26px; border-radius: 50%; background: radial-gradient(circle at 35% 30%, #44474b, #1c1e20); box-shadow: inset 0 1px 0 #5a5d61; }
.knob.joy .joy-cross { position: absolute; color: #6a6d72; font-size: 22px; font-weight: 700; pointer-events: none; }
/* RANGE knob: white surround ring + zoom /+ and curved arrows (like the GDU) */
.knob.joy { overflow: visible; }
.rng-ring { position: absolute; inset: -10px; border-radius: 50%; border: 2px solid #d2d7dc; pointer-events: none; }
.rng-sign { position: absolute; top: 50%; transform: translateY(-50%); color: #d2d7dc; font-size: 17px; font-weight: 700; pointer-events: none; line-height: 1; }
.rng-sign.m { left: -23px; } .rng-sign.p { right: -23px; }
.rng-arc { position: absolute; top: -14px; color: #d2d7dc; font-size: 15px; pointer-events: none; }
.rng-arc.l { left: -10px; } .rng-arc.r { right: -10px; }
.knob.outer { cursor: pointer; border: none; padding: 0; }
.knob.outer:active { box-shadow: 0 1px 2px #000, inset 0 2px 4px #000; }
@@ -316,6 +455,41 @@ body {
.set-opt { display: flex; gap: 8px; }
.set-opt .fbtn { flex: 1; }
.set-hint { color: var(--c-mut); font-size: 11px; margin-top: 10px; line-height: 1.45; font-family: var(--ui-font); }
/* radio tuner — KAP-140 green LCD + macOS-dark launcher chrome */
.rtuner { width: min(400px, 94vw); background: linear-gradient(#23262c, #15171b); border: 1px solid #0a0a0a;
border-top: 1px solid #4a4d52; border-radius: 16px; padding: 16px; font-family: var(--ui-font);
box-shadow: 0 18px 50px rgba(0,0,0,.6), inset 0 1px 0 rgba(255,255,255,.05); }
.rt-head { display: flex; align-items: center; gap: 10px; margin-bottom: 14px; }
.rt-title { color: var(--c-txt); font-size: 18px; font-weight: 700; letter-spacing: .5px; }
.rt-kind { color: #cfd6dd; background: #0f0f0f; border: 1px solid #3a3a3a; font-size: 10px; font-weight: 800; letter-spacing: 1px; padding: 2px 8px; border-radius: 999px; }
/* green LCD with both frequencies */
.rt-lcd { display: flex; align-items: center; justify-content: space-between; gap: 10px;
background: #06160b; border: 1px solid #0a4d24; border-radius: 10px; padding: 12px 14px; margin-bottom: 16px;
box-shadow: inset 0 0 22px rgba(0,90,35,.5); }
.rt-f { display: flex; flex-direction: column; gap: 3px; }
.rt-f.right { align-items: flex-end; }
.rt-f span { color: #1f9d52; font-size: 10px; letter-spacing: 1.5px; }
.rt-f b { font-family: 'Saira Condensed', monospace; font-size: 30px; font-weight: 700; line-height: 1; }
.rt-f b.act { color: #3bff6e; text-shadow: 0 0 12px rgba(59,255,110,.55); }
.rt-f b.sby { color: #f0f0f0; text-shadow: 0 0 8px rgba(240,240,240,.3); }
.rt-swap { flex: 0 0 auto; width: 48px; height: 40px; background: linear-gradient(#2b2b2b, #161616); color: #e0e0e0;
border: 1px solid #08090b; border-top: 1px solid #4a4a4a; border-radius: 9px; font-size: 22px; cursor: pointer;
box-shadow: 0 2px 4px rgba(0,0,0,.5), inset 0 1px 0 rgba(255,255,255,.08); }
.rt-swap:hover { color: #fff; } .rt-swap:active { transform: translateY(1px); background: #3a3a3a; color: #fff; }
.rt-tune { display: flex; flex-direction: column; gap: 10px; }
.rt-row { display: grid; grid-template-columns: 64px 1fr 1fr; gap: 10px; align-items: center; }
.rt-unit { color: var(--c-txt2); font-size: 13px; font-weight: 700; letter-spacing: .5px; text-align: center;
background: #2c2f35; border: 1px solid #3a3f47; border-radius: 8px; padding: 8px 0; }
.rt-step { background: linear-gradient(#3b3e44, #23262b); color: #eef2f6; border: 1px solid #08090b; border-top: 1px solid #5c6168;
border-radius: 10px; padding: 16px 0; font-size: 24px; font-weight: 700; cursor: pointer;
box-shadow: 0 2px 5px rgba(0,0,0,.55), inset 0 1px 0 rgba(255,255,255,.1); }
.rt-step:hover { background: linear-gradient(#454545, #2a2a2a); }
.rt-step:active { transform: translateY(1px); background: linear-gradient(#3a3a3a, #1f1f1f); color: #fff; box-shadow: inset 0 2px 5px rgba(0,0,0,.6); }
.rt-actions { display: flex; gap: 10px; margin-top: 16px; }
.rt-btn { flex: 1; background: #232323; color: var(--c-txt2); border: 1px solid #3a3a3a; border-radius: 10px; padding: 13px; font-family: var(--ui-font); font-size: 14px; font-weight: 700; cursor: pointer; }
.rt-btn:hover { background: #2e2e2e; }
.rt-btn.primary { background: #f0f0f0; color: #0f0f0f; border-color: transparent; }
.rt-btn.primary:active { transform: translateY(1px); filter: brightness(.92); }
.pan-pad { display: grid; grid-template-columns: repeat(2, 14px); gap: 2px; margin-top: 3px; }
.pan-pad button {
@@ -363,6 +537,10 @@ body {
font: 600 12px/1 monospace; color: #0ff; background: #000a; padding: 4px 6px; }
.mc-mode em { width: 8px; height: 8px; border: 1px solid #0ff; display: inline-block; }
.mc-mode em.on { background: #0ff; }
.mc-wind { position: absolute; left: 6px; top: 6px; display: flex; gap: 6px; align-items: center;
font: 600 12px/1 monospace; color: #fff; background: #000a; padding: 4px 7px; }
.mc-wind i { color: #9ab; font-style: normal; }
.mc-windarr { display: inline-block; font-size: 16px; line-height: 1; color: #cfe3ff; }
/* Autopilot — GMC-710-style AFCS mode controller (app chrome look) */
.afcs { width: 100%; height: 100%; display: flex; align-items: center; justify-content: center;
@@ -520,7 +698,7 @@ body {
background: #08240f; color: #29f06a; border: 1px solid #0a5; border-radius: 8px;
padding: 12px 16px; font-weight: 800; font-family: monospace; letter-spacing: 1px;
}
.fbtn.add { background: #0a5; color: #021008; }
.fbtn.add { background: #f0f0f0; color: #0f0f0f; }
.fbtn.export { background: #ffae42; color: #2a1500; border-color: #ffae42; flex: 1; }
.fbtn:disabled { opacity: .4; }
.fms-actions { display: flex; gap: 8px; margin-top: 8px; }