G1000: manual-accurate radios, baro units, declutter, minimums, OBS, audio panel

Aligned to the official X-Plane 1000 manual:
- NAV radio: active RIGHT / standby LEFT (boxed) per S.12 (COM already correct)
- ALT UNIT softkey (IN / HPA) in the PFD submenu, baro readout converts (S.20)
- DCLTR cycles 3 levels (land / +NDB / flight-plan only) with DCLTR-n label (S.56)
- TOPO and TERRAIN are now independent toggles (relief vs awareness overlay) (S.57)
- Barometric MINIMUMS: BARO MIN bug + readout on the altimeter, amber "MINIMUMS"
  annunciation at/below the decision altitude; set via TMR/REF (lifted to App)
- OBS mode: HSI course follows the CRS knob (magenta "OBS"), sequencing suspended
- New Audio Panel tab (COM mic/receive, MKR/DME/ADF, intercom, Display Backup) (S.91)

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
2026-06-02 05:55:56 +02:00
parent 38b048ad41
commit 033a9d406a
10 changed files with 290 additions and 97 deletions
+4 -4
View File
@@ -46,10 +46,10 @@ const ilsApts = new Set(); // ICAOs that have an ILS/LOC approach (for NRST
const awyCells = new Map(); // "ilat,ilon" (segment midpoint) -> [{ la1, lo1, la2, lo2, name }]
const state = { root: null, loaded: false, count: 0, awy: 0 };
function add(id, lat, lon, type) {
function add(id, lat, lon, type, name) {
if (!id || !isFinite(lat) || !isFinite(lon)) return;
const key = id.toUpperCase();
if (!index.has(key)) index.set(key, { id: key, lat, lon, type });
if (!index.has(key)) index.set(key, { id: key, lat, lon, type, name: name || '' });
}
function pushFix(f) {
@@ -101,7 +101,7 @@ async function parseNav(file) {
if (code !== 2 && code !== 3) continue; // 2 = NDB, 3 = VOR/DME
const lat = parseFloat(p[1]), lon = parseFloat(p[2]), id = p[7];
const type = code === 2 ? 'NDB' : 'VOR';
add(id, lat, lon, type);
add(id, lat, lon, type, p.slice(10).join(' '));
if (id && isFinite(lat) && isFinite(lon)) {
// p[4] = frequency (VOR in 10 kHz e.g. 11630 → 116.30; NDB in kHz);
// name is everything after the airport/region columns.
@@ -118,7 +118,7 @@ async function parseAirports(file) {
let icao = null, name = '', elev = 0, placed = false;
const place = (lat, lon) => {
if (!isFinite(lat) || !isFinite(lon)) return;
add(icao, lat, lon, 'APT');
add(icao, lat, lon, 'APT', name);
airports.push({ id: icao.toUpperCase(), lat, lon, name, elev });
placed = true;
};
+17 -5
View File
@@ -10,6 +10,7 @@ import Bezel from './components/Bezel.jsx';
import DirectTo from './components/DirectTo.jsx';
import Proc from './components/Proc.jsx';
import FplPage from './components/FplPage.jsx';
import AudioPanel from './components/AudioPanel.jsx';
// Compact line icons for the nav rail (stroke = currentColor).
const ICONS = {
@@ -19,6 +20,7 @@ const ICONS = {
fms: 'M4 6h14M4 11h14M4 16h9',
ap: 'M11 4a7 7 0 100 14 7 7 0 000-14zM11 4v3M11 15v3M4 11h3M15 11h3',
vfr: 'M11 4a7 7 0 100 14 7 7 0 000-14zM11 11l4.5-3',
audio: 'M11 4a6 6 0 00-6 6v5M17 15v-5a6 6 0 00-6-6M4 14h2.5v4.5H4zM15.5 14H18v4.5h-2.5z',
};
function Icon({ name }) {
return (
@@ -37,6 +39,7 @@ const TABS = [
{ id: 'fms', label: 'FMS' },
{ id: 'vfr', label: 'VFR' },
{ id: 'ap', label: 'Autopilot' },
{ id: 'audio', label: 'Audio' },
];
export default function App() {
@@ -65,8 +68,14 @@ export default function App() {
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.
// MFD map mode (base layer + overlays), switched via the Map-Opt softkeys.
const [mapMode, setMapMode] = useState({ base: 'topo' });
// Altimeter barometric units (false = inHg, true = hectopascal) — PFD ALT UNIT softkey.
const [baroHpa, setBaroHpa] = useState(false);
// Barometric minimums (set in TMR/REF) — shown on the PFD altimeter as BARO MIN.
const [minimums, setMinimums] = useState({ on: false, ft: 500 });
// OBS (omni-bearing select) mode — suspends GPS sequencing, course set by CRS knob.
const [obs, setObs] = useState(false);
// MFD page group (MAP / FPL / NRST) — selected by the FMS knob, like the real G1000.
const MFD_PAGES = ['map', 'fpl', 'nrst'];
const [mfdPage, setMfdPage] = useState('map');
@@ -129,15 +138,17 @@ export default function App() {
inset={inset} onSetInset={setInset} insetMode={insetMode} onInsetMode={setInsetMode}
nrst={nrst} onToggleNrst={() => toggleWin('nrst')} onDirect={() => toggleWin('dto')}
tmr={tmr} onToggleTmr={() => toggleWin('tmr')} dme={dme} onToggleDme={() => toggleWin('dme')}
alerts={alerts} onToggleAlerts={() => toggleWin('alerts')} onProc={() => toggleWin('proc')} onFpl={() => toggleWin('fpl')}>
<PFD values={xp.values} command={xp.command} svt={svt3d} inset={inset} insetMode={insetMode} nrst={nrst} onCloseNrst={() => setWin(null)}
alerts={alerts} onToggleAlerts={() => toggleWin('alerts')} onProc={() => toggleWin('proc')} onFpl={() => toggleWin('fpl')} onClr={() => setWin(null)}
altHpa={baroHpa} onAltUnit={setBaroHpa} obs={obs} onObs={() => setObs((v) => !v)}>
<PFD values={xp.values} command={xp.command} connected={xp.xpConnected} svt={svt3d} inset={inset} insetMode={insetMode} nrst={nrst} onCloseNrst={() => setWin(null)}
tmr={tmr} onCloseTmr={() => setWin(null)} dme={dme} onCloseDme={() => setWin(null)}
alerts={alerts} onCloseAlerts={() => setWin(null)} flightPlan={xp.flightPlan} fp={xp.fp} />
alerts={alerts} onCloseAlerts={() => setWin(null)} baroHpa={baroHpa} obs={obs}
minimums={minimums} onMinimums={setMinimums} flightPlan={xp.flightPlan} fp={xp.fp} />
{dialogs}
</Bezel>
)}
{tab === 'mfd' && (
<Bezel variant="mfd" xp={xp} knobMode={knobMode} mapMode={mapMode} onMapMode={setMapMode} onDirect={() => toggleWin('dto')} onProc={() => toggleWin('proc')} onFms={cycleMfd} onFpl={() => setMfdPage('fpl')}>
<Bezel variant="mfd" xp={xp} knobMode={knobMode} mapMode={mapMode} onMapMode={setMapMode} onDirect={() => toggleWin('dto')} onProc={() => toggleWin('proc')} onFms={cycleMfd} onFpl={() => setMfdPage('fpl')} onClr={() => setWin(null)}>
<MFD values={xp.values} flightPlan={xp.flightPlan} fp={xp.fp} mapMode={mapMode} page={mfdPage} onCycle={cycleMfd} xp={xp} />
{dialogs}
</Bezel>
@@ -146,6 +157,7 @@ export default function App() {
{tab === 'fms' && <CDU xp={xp} />}
{tab === 'vfr' && <VFR xp={xp} />}
{tab === 'ap' && <AutopilotPanel xp={xp} />}
{tab === 'audio' && <AudioPanel xp={xp} />}
</main>
{settings && (
<div className="dlg-backdrop" onClick={() => setSettings(false)}>
+93
View File
@@ -0,0 +1,93 @@
import React, { useState } from 'react';
// X1000 Audio Panel (Manual S.91). Selects which radios are heard, which COM is
// used to transmit (MIC), marker/DME/ADF Morse audio, intercom, and the Display
// Backup (reversionary) key. Selections are local state with authentic lit keys.
//
// COM MIC is single-select (one transmit radio); the receive/audio keys and the
// Morse keys toggle independently — exactly like the real unit.
export default function AudioPanel({ xp }) {
const [mic, setMic] = useState('com1'); // transmit radio: com1 | com2 | tel
const [recv, setRecv] = useState({ com1: true }); // receive/audio selections
const [hiSens, setHiSens] = useState(false);
const [crew, setCrew] = useState('pilot');
const [vol, setVol] = useState(60);
const r = (k) => !!recv[k];
const toggle = (k) => setRecv((s) => ({ ...s, [k]: !s[k] }));
// a single audio key: lit green for MIC (transmit), cyan for receive/Morse
const Key = ({ k, label, sub, on, kind = 'recv', onClick }) => (
<button className={`apk ${kind} ${on ? 'on' : ''}`} onClick={onClick}>
<span className="apk-l">{label}</span>{sub && <span className="apk-s">{sub}</span>}
</button>
);
return (
<div className="audio-panel">
<div className="apnl">
<div className="apnl-title">AUDIO PANEL</div>
<div className="apnl-grp">
<div className="apnl-h">COM</div>
<div className="apnl-row">
<Key label="COM1 MIC" kind="mic" on={mic === 'com1'} onClick={() => setMic('com1')} />
<Key label="COM1" on={r('com1')} onClick={() => toggle('com1')} />
</div>
<div className="apnl-row">
<Key label="COM2 MIC" kind="mic" on={mic === 'com2'} onClick={() => setMic('com2')} />
<Key label="COM2" on={r('com2')} onClick={() => toggle('com2')} />
</div>
<div className="apnl-row">
<Key label="COM 1/2" on={false} onClick={() => setMic((m) => (m === 'com1' ? 'com2' : 'com1'))} />
<Key label="TEL" kind="mic" on={mic === 'tel'} onClick={() => setMic('tel')} />
</div>
</div>
<div className="apnl-grp">
<div className="apnl-h">CABIN / SPEAKER</div>
<div className="apnl-row">
<Key label="PA" on={r('pa')} onClick={() => toggle('pa')} />
<Key label="SPKR" on={r('spkr')} onClick={() => toggle('spkr')} />
</div>
<div className="apnl-row">
<Key label="MKR / MUTE" on={r('mkr')} onClick={() => toggle('mkr')} />
<Key label="HI SENS" on={hiSens} onClick={() => setHiSens((v) => !v)} />
</div>
</div>
<div className="apnl-grp">
<div className="apnl-h">NAV</div>
<div className="apnl-row">
<Key label="DME" on={r('dme')} onClick={() => toggle('dme')} />
<Key label="NAV1" on={r('nav1')} onClick={() => toggle('nav1')} />
</div>
<div className="apnl-row">
<Key label="ADF" on={r('adf')} onClick={() => toggle('adf')} />
<Key label="NAV2" on={r('nav2')} onClick={() => toggle('nav2')} />
</div>
<div className="apnl-row">
<Key label="AUX" on={r('aux')} onClick={() => toggle('aux')} />
<Key label="MAN SQ" on={r('msq')} onClick={() => toggle('msq')} />
</div>
</div>
<div className="apnl-grp">
<div className="apnl-h">CREW · ICS</div>
<div className="apnl-row">
<Key label="PILOT" kind="mic" on={crew === 'pilot'} onClick={() => setCrew('pilot')} />
<Key label="COPLT" kind="mic" on={crew === 'copilot'} onClick={() => setCrew('copilot')} />
</div>
<div className="apnl-vol">
<span>PILOT INTERCOM VOL</span>
<input type="range" min="0" max="100" value={vol} onChange={(e) => setVol(+e.target.value)} />
<b>{vol}</b>
</div>
</div>
<button className="apnl-backup" onClick={() => xp && xp.command && xp.command('mfd_softkey1')} title="Display Backup (reversionary)">
DISPLAY BACKUP
</button>
</div>
</div>
);
}
+24 -15
View File
@@ -14,7 +14,9 @@ import { num, systemAlerts } from '../api/useXplane.js';
// SYN TERR toggles the 3D synthetic-vision terrain on/off.
const PFD_MENU = {
root: ['', 'INSET', '', 'PFD', '', 'CDI', 'DME', 'XPDR', 'IDENT', 'TMR/REF', 'NRST', 'CAUTION'],
pfd: ['PATHWAY', 'SYN TERR', 'HRZN HDG', 'APTSIGNS', '', '', '', '', '', '', '', 'BACK'],
pfd: ['PATHWAY', 'SYN TERR', 'HRZN HDG', 'APTSIGNS', 'ALT UNIT', '', '', '', '', '', '', 'BACK'],
// ALT UNIT submenu: barometric pressure units (inHg / hectopascal), like the manual.
altunit: ['IN', 'HPA', '', '', '', '', '', '', '', '', '', 'BACK'],
// XPDR submenu: standby/on/alt modes, VFR (1200), CODE entry, IDENT.
xpdr: ['STBY', 'ON', 'ALT', 'VFR', '', 'CODE', 'IDENT', '', '', '', '', 'BACK'],
// CODE entry turns the softkeys into the octal squawk keypad (digits 07).
@@ -34,12 +36,11 @@ const MFD_MENU = {
// 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, dme, onToggleDme, alerts, onToggleAlerts, onDirect, onProc, onFpl, onFms, 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, onClr, onFms, mapMode, onMapMode, altHpa, onAltUnit, obs, onObs, 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;
let keys = menu[page] || menu.root;
@@ -62,31 +63,36 @@ export default function Bezel({ variant = 'pfd', xp, svt3d, onToggleSvt, inset,
const onSoftkey = (i, label) => {
fire(`softkey${i + 1}`); // mirror to the in-sim G1000
// declutter cycles through 4 levels (0=all … 3=flight plan only), like the manual
const cycleDcltr = (setter) => setter && setter((m) => ({ ...m, dcltr: (((m.dcltr || 0) + 1) % 4) }));
if (variant === 'mfd') {
if (label === 'MAP') setPage('mapopt');
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 === 'TOPO') setBase('topo'); // relief on/off
else if (label === 'TERRAIN') onMapMode && onMapMode((m) => ({ ...m, terrain: !m.terrain })); // awareness overlay (independent)
else if (label === 'OSM') setBase('osm');
else if (label === 'DCLTR') onMapMode && onMapMode((m) => ({ ...m, dcltr: m.dcltr ? 0 : 1 }));
else if (label === 'DCLTR') cycleDcltr(onMapMode);
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');
else if (label === 'BACK') setPage({ xpdrcode: 'xpdr', altunit: 'pfd' }[page] || 'root');
else if (label === 'SYN TERR') onToggleSvt && onToggleSvt();
else if (label === 'ALT UNIT') setPage('altunit');
else if (label === 'IN') { onAltUnit && onAltUnit(false); setPage('pfd'); }
else if (label === 'HPA') { onAltUnit && onAltUnit(true); setPage('pfd'); }
else if (label === 'INSET') {
if (page === 'root') { onSetInset && onSetInset(true); setPage('inset'); }
else onSetInset && onSetInset(!inset); // toggle from within the submenu
}
else if (label === 'OFF') { onSetInset && onSetInset(false); setPage('root'); }
else if (label === 'DCLTR') onInsetMode && onInsetMode((m) => ({ ...m, dcltr: m.dcltr ? 0 : 1 }));
else if (label === 'TOPO') onInsetMode && onInsetMode((m) => ({ ...m, base: 'topo' }));
else if (label === 'TERRAIN') onInsetMode && onInsetMode((m) => ({ ...m, base: 'terrain' }));
else if (label === 'DCLTR') cycleDcltr(onInsetMode);
else if (label === 'TOPO') onInsetMode && onInsetMode((m) => ({ ...m, base: m.base === 'topo' ? 'dark' : 'topo' }));
else if (label === 'TERRAIN') onInsetMode && onInsetMode((m) => ({ ...m, terrain: !m.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 === 'OBS') onObs && onObs(); // 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);
@@ -102,13 +108,14 @@ export default function Bezel({ variant = 'pfd', xp, svt3d, onToggleSvt, inset,
// which softkey is "lit" right now
const isOn = (label) => {
if (variant === 'mfd') return (label === 'TOPO' && mapMode?.base === 'topo')
|| (label === 'TERRAIN' && mapMode?.base === 'terrain') || (label === 'OSM' && mapMode?.base === 'osm')
|| (label === 'TERRAIN' && mapMode?.terrain) || (label === 'OSM' && mapMode?.base === 'osm')
|| (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)
|| (label === 'IN' && !altHpa) || (label === 'HPA' && altHpa)
|| (page === 'inset' && label === 'TOPO' && insetMode?.base === 'topo')
|| (page === 'inset' && label === 'TERRAIN' && insetMode?.base === 'terrain')
|| (page === 'inset' && label === 'TERRAIN' && insetMode?.terrain)
|| (label === 'DCLTR' && insetMode?.dcltr > 0);
};
@@ -135,7 +142,9 @@ export default function Bezel({ variant = 'pfd', xp, svt3d, onToggleSvt, inset,
{/* 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>
<span key={i} className={`skl ${s ? '' : 'empty'} ${s === 'CAUTION' ? 'caution' : ''} ${isOn(s) ? 'on' : ''}`}>{
(s === 'DCLTR' && (mapMode?.dcltr || insetMode?.dcltr)) ? `DCLTR-${mapMode?.dcltr || insetMode?.dcltr}` : s
}</span>
))}
</div>
</div>
@@ -159,7 +168,7 @@ export default function Bezel({ variant = 'pfd', xp, svt3d, onToggleSvt, inset,
<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" 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>
<BtnG fire={fire} mode={knobMode} cmd="clr" onClick={onClr}>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"
+17 -22
View File
@@ -53,41 +53,36 @@ export default function DirectTo({ xp, onClose }) {
<div className="dlg dto" onClick={(e) => e.stopPropagation()}>
<div className="dlg-head">DIRECT TO</div>
<div className="dto-body">
{/* ident line (cyan, edited like the FMS knob) + resolved name below */}
<input
ref={inputRef}
className="dto-input"
className="dto-ident"
value={entry}
onChange={(e) => { setEntry(e.target.value.toUpperCase()); setSel(null); }}
onKeyDown={(e) => { if (e.key === 'Enter' && sel) activate(); if (e.key === 'Escape') onClose(); }}
placeholder="IDENT (z.B. KSEA, SEA, ELN)"
placeholder="_ _ _ _"
autoCapitalize="characters" autoCorrect="off" spellCheck="false"
/>
{hits.length > 0 && (
<div className="dto-name">{sel ? (sel.name || sel.type) : ' '}</div>
{hits.length > 0 && !sel && (
<div className="dto-hits">
{hits.map((h) => (
<button key={h.id + h.lat} className={sel && sel.id === h.id ? 'on' : ''}
onClick={() => { setSel(h); setEntry(h.id); setHits([]); }}>
<b>{h.id}</b><i>{h.type}</i><span>{h.lat.toFixed(2)}, {h.lon.toFixed(2)}</span>
<button key={h.id + h.lat} onClick={() => { setSel(h); setEntry(h.id); setHits([]); }}>
<b>{h.id}</b><span>{h.name || h.type}</span>
</button>
))}
</div>
)}
{sel && (
<>
<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">
<button className="fbtn" onClick={onClose}>CANCEL</button>
<button className="fbtn add" disabled={!sel} onClick={activate}>ACTIVATE</button>
<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 className="dto-foot">
<button className="dto-act" disabled={!sel} onClick={activate}>ACTIVATE</button>
</div>
</div>
</div>
</div>
+5 -5
View File
@@ -80,15 +80,15 @@ function MfdTopBar({ V, fp }) {
<rect x="0" y="0" width="1000" height="70" fill="#000" />
{[300, 660].map((x) => <line key={x} x1={x} y1="2" x2={x} y2="68" stroke="#333" strokeWidth="1.5" />)}
<line x1="0" y1="70" x2="1000" y2="70" stroke="#3a3a3a" strokeWidth="2" />
{/* NAV1 / NAV2 */}
{/* NAV1 / NAV2 — standby LEFT (cyan, boxed), active RIGHT (white) per manual */}
<text x="10" y="27" fill="#fff" fontSize="13">NAV1</text>
<rect x="50" y="11" width="80" height="21" fill="none" stroke="#0ff" strokeWidth="1.3" />
<text x="126" y="27" fill="#0ff" fontSize="17" textAnchor="end">{navF(V.nav1)}</text>
<text x="126" y="27" fill="#0ff" fontSize="17" textAnchor="end">{navF(V.nav1Sb)}</text>
{swap(150, 27)}
<text x="174" y="27" fill="#fff" fontSize="17">{navF(V.nav1Sb)}</text>
<text x="174" y="27" fill="#fff" fontSize="17">{navF(V.nav1)}</text>
<text x="10" y="58" fill="#fff" fontSize="13">NAV2</text>
<text x="126" y="58" fill="#fff" fontSize="17" textAnchor="end">{navF(V.nav2)}</text>
<text x="174" y="58" fill="#fff" fontSize="17">{navF(V.nav2Sb)}</text>
<text x="126" y="58" fill="#0ff" fontSize="17" textAnchor="end">{navF(V.nav2Sb)}</text>
<text x="174" y="58" fill="#fff" fontSize="17">{navF(V.nav2)}</text>
{/* centre: GS/DTK/TRK/ETE + active mode line */}
<text x="312" y="27" fill="#fff" fontSize="13">GS</text>
<text x="350" y="27" fill="#e040fb" fontSize="15" fontWeight="bold">{gs}</text>
+8 -5
View File
@@ -152,10 +152,13 @@ export default function MapView({ values, flightPlan, fp, inset = false, hud = t
const layer = navLayerRef.current;
if (!layer) return;
const z = map.getZoom();
if (z < 6 || dcltrRef.current > 0) { layer.clearLayers(); return; }
const types = z >= 10 ? 'apt,vor,ndb,fix' : z >= 8 ? 'apt,vor,ndb' : 'apt';
const dc = dcltrRef.current || 0; // 0 all · 1 drop fixes · 2 drop fixes+NDB · 3 flight-plan only
if (z < 6 || dc >= 3) { layer.clearLayers(); return; }
let types = z >= 10 ? ['apt', 'vor', 'ndb', 'fix'] : z >= 8 ? ['apt', 'vor', 'ndb'] : ['apt'];
if (dc >= 1) types = types.filter((t) => t !== 'fix');
if (dc >= 2) types = types.filter((t) => t !== 'ndb');
const b = map.getBounds();
const url = `/api/nav/bbox?s=${b.getSouth()}&w=${b.getWest()}&n=${b.getNorth()}&e=${b.getEast()}&types=${types}&limit=${z >= 10 ? 500 : 250}`;
const url = `/api/nav/bbox?s=${b.getSouth()}&w=${b.getWest()}&n=${b.getNorth()}&e=${b.getEast()}&types=${types.join(',')}&limit=${z >= 10 ? 500 : 250}`;
try {
navAbortRef.current?.abort();
navAbortRef.current = new AbortController();
@@ -208,7 +211,7 @@ export default function MapView({ values, flightPlan, fp, inset = false, hud = t
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;
const show = !!mapMode?.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;
@@ -234,7 +237,7 @@ export default function MapView({ values, flightPlan, fp, inset = false, hud = t
terrRef.current.setBounds(bounds);
terrRef.current.setUrl(cv.toDataURL());
}
}, [terrain, base]); // eslint-disable-line
}, [terrain, mapMode?.terrain]); // 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
+70 -17
View File
@@ -132,7 +132,7 @@ function useEasedAngle(target, tau = 0.08) {
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 }) {
export default function PFD({ values: V, command, connected = true, svt = true, inset = false, insetMode, nrst = false, onCloseNrst, tmr = false, onCloseTmr, dme = false, onCloseDme, alerts = false, onCloseAlerts, baroHpa = false, obs = false, minimums, onMinimums, flightPlan, fp }) {
const wrapRef = useRef(null);
const svgRef = useRef(null);
const [box, setBox] = useState(null);
@@ -151,6 +151,15 @@ export default function PFD({ values: V, command, svt = true, inset = false, ins
const map = (b) => ({ left: offX + b.x * scale, top: offY + b.y * scale, width: b.w * scale, height: b.h * scale });
setBox(map(SVT_BOX));
setInsetBox(map(INSET_BOX));
// Window zone (lower-right quadrant): right = altitude tape's right edge
// (x≈938), bottom just above the XPDR strip (y≈736), left clear of the HSI
// rose (x≈650), top below the baro box (y≈502). Exposed as CSS vars so the
// windows sit embedded and never cover the HSI or the baro readout.
const root = document.documentElement;
root.style.setProperty('--gwin-right', `${Math.max(8, wr.width - (offX + 938 * scale))}px`);
root.style.setProperty('--gwin-bottom', `${Math.max(8, wr.height - (offY + 736 * scale))}px`);
root.style.setProperty('--gwin-maxw', `${(938 - 650) * scale}px`);
root.style.setProperty('--gwin-maxh', `${(736 - 502) * scale}px`);
};
measure();
const ro = new ResizeObserver(measure);
@@ -199,15 +208,25 @@ export default function PFD({ values: V, command, svt = true, inset = false, ins
<AFCS V={V} />
<Marker V={V} />
<AirspeedTape V={V} ias={iasS} />
<AltitudeTape V={V} alt={altS} vs={vsS} />
<AltitudeTape V={V} alt={altS} vs={vsS} baroHpa={baroHpa} minimums={minimums} />
<GlideSlope V={V} />
<HSI V={V} nav={nav} hdg={hdgS} />
<HSI V={V} nav={nav} hdg={hdgS} obs={obs} />
<HdgCrsBoxes V={V} nav={nav} />
<Wind V={V} />
<DataStrip V={V} />
{/* sensor-failure flags (red X) when X-Plane isn't feeding data — the GDU
blanks the affected display and shows a red X, like the real unit */}
{!connected && (
<g>
<RedX x={150} y={113} w={700} h={322} label="AHRS" />
<RedX x={60} y={110} w={84} h={350} />
<RedX x={W - 154} y={110} w={84} h={350} />
<RedX x={W / 2 - 140} y={500} w={280} h={260} label="HDG" />
</g>
)}
</svg>
{nrst && <Nearest values={V} onClose={onCloseNrst} />}
{tmr && <TimerRef values={V} onClose={onCloseTmr} />}
{tmr && <TimerRef values={V} onClose={onCloseTmr} minimums={minimums} onMinimums={onMinimums} />}
{dme && <DmeWindow V={V} onClose={onCloseDme} />}
{alerts && <AlertsWindow V={V} onClose={onCloseAlerts} />}
{tune && <RadioTuner values={V} command={command} radio={tune} onClose={() => setTune(null)} />}
@@ -228,16 +247,16 @@ function RadioBar({ V, onTune }) {
{[330, 560, 690].map((x) => <line key={x} x1={x} y1="2" x2={x} y2="72" stroke="#333" strokeWidth="1.5" />)}
<line x1="0" y1="74" x2={W} y2="74" stroke="#3a3a3a" strokeWidth="2" />
{/* NAV1 / NAV2 (left) */}
{/* NAV1 / NAV2 — per manual: standby LEFT (cyan, boxed/tunable), active RIGHT (white) */}
<text x="14" y="28" fill="#fff" fontSize="14">NAV1</text>
<rect x="58" y="11" width="92" height="22" fill="none" stroke="#0ff" strokeWidth="1.4" />
<text x="146" y="28" fill="#0ff" fontSize="19" textAnchor="end">{navF(V.nav1)}</text>
<text x="146" y="28" fill="#0ff" fontSize="19" textAnchor="end">{navF(V.nav1Sb)}</text>
{swap(176, 28)}
<text x="206" y="28" fill="#fff" fontSize="19">{navF(V.nav1Sb)}</text>
<text x="206" y="28" fill="#fff" fontSize="19">{navF(V.nav1)}</text>
<text x="14" y="60" fill="#fff" fontSize="14">NAV2</text>
<text x="146" y="60" fill="#0ff" fontSize="19" textAnchor="end">{navF(V.nav2)}</text>
<text x="146" y="60" fill="#0ff" fontSize="19" textAnchor="end">{navF(V.nav2Sb)}</text>
{swap(176, 60)}
<text x="206" y="60" fill="#fff" fontSize="19">{navF(V.nav2Sb)}</text>
<text x="206" y="60" fill="#fff" fontSize="19">{navF(V.nav2)}</text>
{/* centre: active leg + DIS/BRG */}
<text x="430" y="26" fill="#e040fb" fontSize="20" textAnchor="middle">{'→'}</text>
@@ -272,6 +291,20 @@ function RadioBar({ V, onTune }) {
);
}
// Red-X failure flag over a blanked instrument region (no valid sensor data).
function RedX({ x, y, w, h, label }) {
return (
<g>
<rect x={x} y={y} width={w} height={h} fill="#0a0d10" opacity="0.82" />
<line x1={x} y1={y} x2={x + w} y2={y + h} stroke="#e01010" strokeWidth="4" />
<line x1={x + w} y1={y} x2={x} y2={y + h} stroke="#e01010" strokeWidth="4" />
{label && (
<text x={x + w / 2} y={y + h / 2 + 6} textAnchor="middle" fill="#ffce46" fontSize="22" fontWeight="bold" fontFamily="monospace">{label}</text>
)}
</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.
@@ -555,7 +588,7 @@ function AirspeedTape({ V, ias: iasProp }) {
}
/* ---------------- altitude tape + VSI + baro ---------------- */
function AltitudeTape({ V, alt: altProp, vs: vsProp }) {
function AltitudeTape({ V, alt: altProp, vs: vsProp, baroHpa = false, minimums }) {
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);
@@ -624,7 +657,21 @@ function AltitudeTape({ V, alt: altProp, vs: vsProp }) {
</g>
{/* baro */}
<rect x={x} y={top + h + 10} width={W2} height={26} fill="#000" stroke="#3a3a3a" />
<text x={x + W2 / 2} y={top + h + 29} textAnchor="middle" fill="#0ff" fontSize="16">{baro.toFixed(2)} IN</text>
<text x={x + W2 / 2} y={top + h + 29} textAnchor="middle" fill="#0ff" fontSize="16">{baroHpa ? `${Math.round(baro * 33.8639)} HPA` : `${baro.toFixed(2)} IN`}</text>
{/* barometric minimums (BARO MIN): cyan bug on the tape + readout, amber
"MINIMUMS" annunciation when at/below the decision altitude */}
{minimums?.on && (
<g fontFamily="monospace">
{(() => { const my = Math.max(top, Math.min(top + h, cy + (alt - minimums.ft) * px)); return (
<g><path d={`M${x} ${my} l-9 -6 v12 z`} fill="#19b8e6" /><text x={x - 12} y={my + 5} textAnchor="end" fill="#19b8e6" fontSize="11" fontWeight="bold">B</text></g>
); })()}
<rect x={x} y={top + h + 40} width={W2} height="22" fill="#000" stroke="#19395a" />
<text x={x + 4} y={top + h + 56} fill="#19b8e6" fontSize="12">BARO {minimums.ft}<tspan fill="#0c9" fontSize="10">FT</tspan></text>
{alt > 0 && alt <= minimums.ft && (
<text x={x + W2 / 2} y={cy + 46} textAnchor="middle" fill="#ffce46" fontSize="15" fontWeight="bold">MINIMUMS</text>
)}
</g>
)}
{/* VSI to the right */}
<VSI x={x + W2 + 34} cy={cy} h={h} vs={vs} bug={num(V.apVsBug)} />
</g>
@@ -651,7 +698,7 @@ function VSI({ x, cy, h, vs, bug }) {
}
/* ---------------- HSI compass rose ---------------- */
function HSI({ V, nav, hdg: hdgProp }) {
function HSI({ V, nav, hdg: hdgProp, obs = false }) {
const hdg = hdgProp != null ? hdgProp : ((num(V.heading) % 360) + 360) % 360;
const bug = num(V.apHdgBug);
// CDI source mirrors the in-sim G1000: 2 = GPS (magenta), 0/1 = VLOC1/2 (green).
@@ -659,12 +706,18 @@ function HSI({ V, nav, hdg: hdgProp }) {
// 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;
// OBS mode (GPS): the course is the pilot-set OBS course (CRS knob); cross-track
// is measured against the OBS radial through the active waypoint. Sequencing is
// suspended (the leg does not auto-advance).
const obsActive = obs && isGps && !!nav;
const useNav = isGps && !!nav && !obsActive;
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 srcLabel = obsActive ? 'OBS' : isGps ? 'GPS' : (src === 1 ? 'VLOC2' : 'VLOC1');
const obsCrs = num(V.obsCrs, 360);
const obsDef = obsActive ? Math.max(-2.5, Math.min(2.5, -(nav.dist * Math.sin((nav.brg - obsCrs) * D2R)) / 1.0)) : 0;
const crs = obsActive ? obsCrs : useNav ? nav.dtk : obsCrs;
const def = obsActive ? obsDef : useNav ? nav.def : num(V.hsiDef);
const toFrom = (useNav || obsActive) ? 1 : num(V.hsiToFrom);
const cx = W / 2, cy = 630, r = 130;
const ticks = [];
+5 -3
View File
@@ -19,14 +19,16 @@ function fmt(sec) {
return h > 0 ? `${pad(h)}:${pad(m)}:${pad(ss)}` : `${pad(m)}:${pad(ss)}`;
}
export default function TimerRef({ values, onClose }) {
export default function TimerRef({ values, onClose, minimums = { on: false, ft: 500 }, onMinimums }) {
const [dir, setDir] = useState('up'); // 'up' | 'dn'
const [running, setRunning] = useState(false);
const [elapsed, setElapsed] = useState(0); // seconds
const [target, setTarget] = useState(300); // count-down start (s)
const [vbugs, setVbugs] = useState({}); // key -> bool (shown on tape, future)
const [minsOn, setMinsOn] = useState(false);
const [mins, setMins] = useState(500); // baro minimums (ft)
// Minimums are lifted to App so the PFD altimeter can show the BARO MIN bug.
const minsOn = minimums.on, mins = minimums.ft;
const setMins = (fn) => onMinimums && onMinimums((m) => ({ ...m, ft: Math.max(0, typeof fn === 'function' ? fn(m.ft) : fn) }));
const setMinsOn = (fn) => onMinimums && onMinimums((m) => ({ ...m, on: typeof fn === 'function' ? fn(m.on) : fn }));
const tickRef = useRef(null);
useEffect(() => {
+47 -21
View File
@@ -162,7 +162,8 @@ body {
/* 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; right: 2%; top: 50%; bottom: 11%; width: 31%; max-width: 320px;
position: absolute; z-index: 4; right: var(--gwin-right, 4%); bottom: var(--gwin-bottom, 6%); top: auto;
width: var(--gwin-maxw, 290px); max-height: var(--gwin-maxh, 44%);
display: flex; flex-direction: column;
background: #05080b; border: 1px solid #7e8a94; border-radius: 0;
color: #fff; font-family: 'Roboto Mono', monospace;
@@ -200,10 +201,12 @@ body {
.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;
position: absolute; z-index: 4; right: var(--gwin-right, 4%); bottom: var(--gwin-bottom, 6%); left: auto; top: auto;
width: var(--gwin-maxw, 290px); max-height: var(--gwin-maxh, 44%);
background: #05080b; border: 1px solid #7e8a94; border-radius: 0;
color: #fff; font-family: 'Roboto Mono', monospace;
}
.pfd-pop.alerts { top: auto; }
.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; }
@@ -213,7 +216,6 @@ body {
/* 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; } }
@@ -288,7 +290,8 @@ body {
.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; bottom: 11%; right: 2%; width: 31%; max-width: 320px;
position: absolute; z-index: 4; right: var(--gwin-right, 4%); bottom: var(--gwin-bottom, 6%);
width: var(--gwin-maxw, 290px); max-height: var(--gwin-maxh, 44%); overflow-y: auto;
background: #05080b; border: 1px solid #7e8a94; border-radius: 0;
color: #fff; font-family: 'Roboto Mono', monospace;
}
@@ -317,30 +320,35 @@ body {
.dlg-backdrop { position: fixed; inset: 0; z-index: 20; background: rgba(0,0,0,0.55); display: flex; align-items: center; justify-content: center; }
/* 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; }
.gwin-backdrop { position: absolute; inset: 0; z-index: 20; background: transparent; display: flex; align-items: flex-end; justify-content: flex-end; padding: 0 var(--gwin-right, 4%) var(--gwin-bottom, 6%) 0; }
.dlg { background: #05080b; border: 1px solid #7e8a94; border-radius: 0; min-width: 0; color: #fff; font-family: 'Roboto Mono', monospace; }
/* G1000 side-windows fill the lower-right zone (clear of HSI + baro box) */
.gwin-backdrop .dlg, .fpl.win { width: var(--gwin-maxw, 290px); max-width: var(--gwin-maxw, 290px); max-height: var(--gwin-maxh, 44%); }
.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: #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-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-body { padding: 10px 12px 12px; }
.dto-ident { display: block; width: 100%; box-sizing: border-box; background: none; border: none; border-bottom: 1px solid #2c343c;
color: #36d2ff; font: inherit; font-size: 24px; font-weight: bold; letter-spacing: 3px; padding: 2px 2px 4px; text-transform: uppercase; outline: none; }
.dto-ident::placeholder { color: #2c4a57; }
.dto-name { color: #cdd6dd; font-size: 13px; min-height: 17px; padding: 3px 2px 0; }
.dto-hits { display: flex; flex-direction: column; gap: 2px; margin-top: 5px; }
.dto-hits button { display: flex; align-items: baseline; gap: 10px; background: #0c1116; border: 1px solid #1c242c; color: #cfd6dd; font: inherit; padding: 4px 8px; cursor: pointer; text-align: left; }
.dto-hits button:hover { background: #13202a; border-color: #36d2ff; }
.dto-hits button b { color: #36d2ff; } .dto-hits button span { color: #6f808d; font-size: 11px; margin-left: auto; }
.dto-grid { display: grid; grid-template-columns: auto 1fr auto 1fr; align-items: baseline; gap: 7px 8px; margin-top: 10px; padding-top: 9px; border-top: 1px solid #222; }
.dto-grid b { color: #6f808d; font-weight: normal; font-size: 12px; }
.dto-grid span { color: #fff; font-size: 15px; }
.dto-foot { display: flex; justify-content: flex-end; margin-top: 12px; }
.dto-act { background: #0c1116; border: 1px solid #7e8a94; color: #36d2ff; font: inherit; font-weight: bold; letter-spacing: 1px; font-size: 14px; padding: 5px 14px; cursor: pointer; }
.dto-act:hover:not(:disabled) { background: #19b8e6; color: #042230; border-color: #19b8e6; }
.dto-act:disabled { opacity: .4; cursor: default; }
.dlg-actions { display: flex; gap: 8px; padding: 10px 12px; border-top: 1px solid #2c343c; }
.dlg-actions .fbtn { flex: 1; }
/* PROC dialog */
.dlg.proc { width: 400px; max-width: 38%; }
.dlg.proc.menu { width: 300px; }
.dlg.proc, .dlg.proc.menu { width: var(--gwin-maxw, 290px); max-width: var(--gwin-maxw, 290px); display: flex; flex-direction: column; }
.dlg.proc .proc-body { flex: 1; min-height: 0; display: flex; flex-direction: column; }
.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 { background: none; border: none; border-bottom: 1px solid #11161b; color: #d7e2ea; font: inherit; font-family: 'Roboto Mono', monospace; font-size: 13px; text-align: left; padding: 6px 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; }
@@ -353,7 +361,7 @@ body {
.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: #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-cols { display: grid; grid-template-columns: 1fr 1fr 1.3fr; gap: 5px; flex: 1; min-height: 0; }
.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; }
@@ -705,3 +713,21 @@ body {
.fms-export { margin-top: 8px; font-size: 13px; padding: 8px; border-radius: 6px; }
.fms-export.ok { background: #06330f; color: #9f9; }
.fms-export.err { background: #330606; color: #f99; }
/* ---------------- Audio Panel (X1000) ---------------- */
.audio-panel { width: 100%; height: 100%; display: flex; align-items: center; justify-content: center; background: var(--c-bg, #0f0f0f); }
.apnl { width: min(420px, 94%); background: #15191e; border: 1px solid #2c343c; border-radius: 10px; padding: 14px 16px 18px; box-shadow: 0 8px 30px rgba(0,0,0,.5); font-family: var(--ui-font, 'Inter', system-ui); }
.apnl-title { text-align: center; color: #36d2ff; font-weight: 700; letter-spacing: 2px; font-size: 14px; margin-bottom: 12px; }
.apnl-grp { margin-bottom: 12px; }
.apnl-h { color: #6f808d; font-size: 10px; letter-spacing: 1px; margin-bottom: 5px; }
.apnl-row { display: grid; grid-template-columns: 1fr 1fr; gap: 8px; margin-bottom: 8px; }
.apk { display: flex; flex-direction: column; align-items: center; gap: 2px; background: #1c2229; border: 1px solid #313a44; border-radius: 6px; color: #c9d3db; font: inherit; font-size: 12px; font-weight: 600; padding: 9px 6px; cursor: pointer; box-shadow: inset 0 1px 0 rgba(255,255,255,.04); }
.apk:hover { background: #232a32; }
.apk .apk-s { font-size: 9px; color: #6f808d; font-weight: 400; }
.apk.on { border-color: #19b8e6; background: #0d2c38; color: #7fe0ff; box-shadow: 0 0 0 1px #19b8e6, 0 0 10px rgba(25,184,230,.25); }
.apk.mic.on { border-color: #16c116; background: #0c2a0c; color: #7bf07b; box-shadow: 0 0 0 1px #16c116, 0 0 10px rgba(22,193,22,.25); }
.apnl-vol { display: flex; align-items: center; gap: 8px; margin-top: 6px; color: #9fb0bd; font-size: 11px; }
.apnl-vol input { flex: 1; accent-color: #19b8e6; }
.apnl-vol b { color: #fff; font-size: 12px; min-width: 26px; text-align: right; }
.apnl-backup { width: 100%; margin-top: 6px; background: #3a0d0d; border: 1px solid #b53333; border-radius: 8px; color: #ff8a8a; font: inherit; font-weight: 700; letter-spacing: 1px; font-size: 12px; padding: 11px; cursor: pointer; }
.apnl-backup:hover { background: #5a1414; color: #ffb0b0; }