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:
+14
-2
@@ -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
@@ -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 }}>
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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 && (
|
||||
|
||||
@@ -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">
|
||||
|
||||
@@ -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
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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 100–1000 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 });
|
||||
|
||||
@@ -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
@@ -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);
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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
@@ -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; }
|
||||
|
||||
Reference in New Issue
Block a user