From 033a9d406a96e3961d278210b26d440a9f9fe2f9 Mon Sep 17 00:00:00 2001 From: karim Date: Tue, 2 Jun 2026 05:55:56 +0200 Subject: [PATCH] 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 --- server/navdata.js | 8 +-- web/src/App.jsx | 22 ++++++-- web/src/components/AudioPanel.jsx | 93 +++++++++++++++++++++++++++++++ web/src/components/Bezel.jsx | 39 ++++++++----- web/src/components/DirectTo.jsx | 39 ++++++------- web/src/components/MFD.jsx | 10 ++-- web/src/components/MapView.jsx | 13 +++-- web/src/components/PFD.jsx | 87 +++++++++++++++++++++++------ web/src/components/TimerRef.jsx | 8 ++- web/src/styles.css | 68 +++++++++++++++------- 10 files changed, 290 insertions(+), 97 deletions(-) create mode 100644 web/src/components/AudioPanel.jsx diff --git a/server/navdata.js b/server/navdata.js index aac5fa5..9f8ddaa 100644 --- a/server/navdata.js +++ b/server/navdata.js @@ -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; }; diff --git a/web/src/App.jsx b/web/src/App.jsx index 6fa8110..d37ceab 100644 --- a/web/src/App.jsx +++ b/web/src/App.jsx @@ -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')}> - 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)}> + 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} )} {tab === 'mfd' && ( - toggleWin('dto')} onProc={() => toggleWin('proc')} onFms={cycleMfd} onFpl={() => setMfdPage('fpl')}> + toggleWin('dto')} onProc={() => toggleWin('proc')} onFms={cycleMfd} onFpl={() => setMfdPage('fpl')} onClr={() => setWin(null)}> {dialogs} @@ -146,6 +157,7 @@ export default function App() { {tab === 'fms' && } {tab === 'vfr' && } {tab === 'ap' && } + {tab === 'audio' && } {settings && (
setSettings(false)}> diff --git a/web/src/components/AudioPanel.jsx b/web/src/components/AudioPanel.jsx new file mode 100644 index 0000000..aaad271 --- /dev/null +++ b/web/src/components/AudioPanel.jsx @@ -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 }) => ( + + ); + + return ( +
+
+
AUDIO PANEL
+ +
+
COM
+
+ setMic('com1')} /> + toggle('com1')} /> +
+
+ setMic('com2')} /> + toggle('com2')} /> +
+
+ setMic((m) => (m === 'com1' ? 'com2' : 'com1'))} /> + setMic('tel')} /> +
+
+ +
+
CABIN / SPEAKER
+
+ toggle('pa')} /> + toggle('spkr')} /> +
+
+ toggle('mkr')} /> + setHiSens((v) => !v)} /> +
+
+ +
+
NAV
+
+ toggle('dme')} /> + toggle('nav1')} /> +
+
+ toggle('adf')} /> + toggle('nav2')} /> +
+
+ toggle('aux')} /> + toggle('msq')} /> +
+
+ +
+
CREW · ICS
+
+ setCrew('pilot')} /> + setCrew('copilot')} /> +
+
+ PILOT INTERCOM VOL + setVol(+e.target.value)} /> + {vol} +
+
+ + +
+
+ ); +} diff --git a/web/src/components/Bezel.jsx b/web/src/components/Bezel.jsx index 3907c12..5a9a95f 100644 --- a/web/src/components/Bezel.jsx +++ b/web/src/components/Bezel.jsx @@ -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 0–7). @@ -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 */}
{keys.map((s, i) => ( - {s} + { + (s === 'DCLTR' && (mapMode?.dcltr || insetMode?.dcltr)) ? `DCLTR-${mapMode?.dcltr || insetMode?.dcltr}` : s + } ))}
@@ -159,7 +168,7 @@ export default function Bezel({ variant = 'pfd', xp, svt3d, onToggleSvt, inset,
D→MENU FPLPROC - CLRENT + CLRENT
e.stopPropagation()}>
DIRECT TO
+ {/* ident line (cyan, edited like the FMS knob) + resolved name below */} { 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 && ( +
{sel ? (sel.name || sel.type) : ' '}
+ {hits.length > 0 && !sel && (
{hits.map((h) => ( - ))}
)} - {sel && ( - <> -
{sel.id}{sel.type}
-
- ALT_____FTOFFSET+0NM - BRG{preview ? `${String(Math.round(preview.brg)).padStart(3, '0')}°` : '___°'} - DIS{preview ? `${preview.dist.toFixed(1)}NM` : '__._NM'} - CRS{preview ? `${String(Math.round(preview.brg)).padStart(3, '0')}°` : '___°'} - -
- - )} -
-
- - +
+ ALT_____FTOFFSET+0NM + BRG{preview ? `${String(Math.round(preview.brg)).padStart(3, '0')}°` : '___°'} + DIS{preview ? `${preview.dist.toFixed(1)}NM` : '__._NM'} + CRS{preview ? `${String(Math.round(preview.brg)).padStart(3, '0')}°` : '___°'} + +
+
+ +
diff --git a/web/src/components/MFD.jsx b/web/src/components/MFD.jsx index 8d5d72c..0fcf0b5 100644 --- a/web/src/components/MFD.jsx +++ b/web/src/components/MFD.jsx @@ -80,15 +80,15 @@ function MfdTopBar({ V, fp }) { {[300, 660].map((x) => )} - {/* NAV1 / NAV2 */} + {/* NAV1 / NAV2 — standby LEFT (cyan, boxed), active RIGHT (white) per manual */} NAV1 - {navF(V.nav1)} + {navF(V.nav1Sb)} {swap(150, 27)} - {navF(V.nav1Sb)} + {navF(V.nav1)} NAV2 - {navF(V.nav2)} - {navF(V.nav2Sb)} + {navF(V.nav2Sb)} + {navF(V.nav2)} {/* centre: GS/DTK/TRK/ETE + active mode line */} GS {gs} diff --git a/web/src/components/MapView.jsx b/web/src/components/MapView.jsx index efc5da7..ad50acf 100644 --- a/web/src/components/MapView.jsx +++ b/web/src/components/MapView.jsx @@ -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 diff --git a/web/src/components/PFD.jsx b/web/src/components/PFD.jsx index 1ac81aa..1e0a397 100644 --- a/web/src/components/PFD.jsx +++ b/web/src/components/PFD.jsx @@ -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 - + - + + {/* 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 && ( + + + + + + + )} {nrst && } - {tmr && } + {tmr && } {dme && } {alerts && } {tune && setTune(null)} />} @@ -228,16 +247,16 @@ function RadioBar({ V, onTune }) { {[330, 560, 690].map((x) => )} - {/* NAV1 / NAV2 (left) */} + {/* NAV1 / NAV2 — per manual: standby LEFT (cyan, boxed/tunable), active RIGHT (white) */} NAV1 - {navF(V.nav1)} + {navF(V.nav1Sb)} {swap(176, 28)} - {navF(V.nav1Sb)} + {navF(V.nav1)} NAV2 - {navF(V.nav2)} + {navF(V.nav2Sb)} {swap(176, 60)} - {navF(V.nav2Sb)} + {navF(V.nav2)} {/* centre: active leg + DIS/BRG */} {'→'} @@ -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 ( + + + + + {label && ( + {label} + )} + + ); +} + /* ---------------- 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 }) { {/* baro */} - {baro.toFixed(2)} IN + {baroHpa ? `${Math.round(baro * 33.8639)} HPA` : `${baro.toFixed(2)} IN`} + {/* barometric minimums (BARO MIN): cyan bug on the tape + readout, amber + "MINIMUMS" annunciation when at/below the decision altitude */} + {minimums?.on && ( + + {(() => { const my = Math.max(top, Math.min(top + h, cy + (alt - minimums.ft) * px)); return ( + B + ); })()} + + BARO {minimums.ft}FT + {alt > 0 && alt <= minimums.ft && ( + MINIMUMS + )} + + )} {/* VSI to the right */} @@ -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 = []; diff --git a/web/src/components/TimerRef.jsx b/web/src/components/TimerRef.jsx index d50e437..9d841e1 100644 --- a/web/src/components/TimerRef.jsx +++ b/web/src/components/TimerRef.jsx @@ -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(() => { diff --git a/web/src/styles.css b/web/src/styles.css index 2a65f3a..e2fece0 100644 --- a/web/src/styles.css +++ b/web/src/styles.css @@ -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; }