Initial commit: X-Plane G1000 web cockpit + bridge + Tauri desktop app

- server/: Node bridge (datarefs/commands, navdata, CIFP procedures, flight plan)
- web/: React cockpit (PFD/MFD/Map, VFR six-pack, AFCS, FMS CDU), PWA, collapsible sidebar
- desktop/: Tauri 2 launcher (Bun sidecar, system tray, updater) + Linux build via Docker
- scripts/: prep-desktop, build-linux, Gitea release + latest.json

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
2026-06-01 15:07:03 +02:00
commit ebc33a78b7
110 changed files with 14671 additions and 0 deletions
+83
View File
@@ -0,0 +1,83 @@
import React from 'react';
import { num } from '../api/useXplane.js';
// Autopilot / AFCS mode-control panel — styled like a Garmin GMC-710 mode
// controller: an annunciator bar on top (active = green, armed = white), a row
// of lit mode keys, and selectors (HDG / ALT / VS / IAS) with knob steppers.
// Buttons fire X-Plane's own autopilot commands; the sim stays the source of truth.
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,
};
const on = (s, b) => (num(s) & b) !== 0;
export default function AutopilotPanel({ xp }) {
const { values: V, command, setDataref } = xp;
const s = num(V.apState);
const eng = num(V.apEngaged) > 0;
const lateral = on(s, AP_BITS.apr) ? 'APR' : on(s, AP_BITS.nav) ? 'NAV' : on(s, AP_BITS.hdg) ? 'HDG' : 'ROL';
const vertical = on(s, AP_BITS.flc) ? 'FLC' : on(s, AP_BITS.vs) ? 'VS' : on(s, AP_BITS.vnav) ? 'VNV' : on(s, AP_BITS.altHold) ? 'ALT' : 'PIT';
const Key = ({ label, cmd, active }) => (
<button className={`apk ${active ? 'on' : ''}`} onClick={() => command(cmd)}>{label}</button>
);
const Sel = ({ label, value, unit, alias, step, dn, up, pad }) => (
<div className="apsel">
<div className="apsel-lbl">{label}</div>
<div className="apsel-val">{pad ? String(((Math.round(value) % 360) + 360) % 360).padStart(3, '0') : Math.round(value)}<span>{unit}</span></div>
<div className="apsel-knob">
<button onClick={() => (dn ? command(dn) : setDataref(alias, value - step))}></button>
<span className="knobdot" />
<button onClick={() => (up ? command(up) : setDataref(alias, value + step))}></button>
</div>
</div>
);
return (
<div className="afcs">
<div className="afcs-unit">
{/* annunciator bar */}
<div className="afcs-ann">
<span className={`ann ${eng ? 'on' : ''}`}>AP</span>
<span className={`ann ${on(s, AP_BITS.fd) ? 'on' : ''}`}>FD</span>
<span className="ann-sep" />
<span className="ann mode on">{lateral}</span>
<span className="ann-gap" />
<span className="ann mode on">{vertical}</span>
<span className="ann val">{Math.round(num(V.apAltBug))}<i>FT</i></span>
</div>
{/* mode keys */}
<div className="afcs-keys">
<Key label="AP" cmd="apToggle" active={eng} />
<Key label="FD" cmd="fdToggle" active={on(s, AP_BITS.fd)} />
<Key label="HDG" cmd="hdg" active={on(s, AP_BITS.hdg)} />
<Key label="NAV" cmd="nav" active={on(s, AP_BITS.nav)} />
<Key label="APR" cmd="apr" active={on(s, AP_BITS.apr)} />
<Key label="BC" cmd="backCourse" active={on(s, AP_BITS.bc)} />
<Key label="ALT" cmd="altHold" active={on(s, AP_BITS.altHold)} />
<Key label="VS" cmd="vs" active={on(s, AP_BITS.vs)} />
<Key label="VNV" cmd="vnav" active={on(s, AP_BITS.vnav)} />
<Key label="FLC" cmd="flc" active={on(s, AP_BITS.flc)} />
</div>
{/* selectors */}
<div className="afcs-sels">
<Sel label="HDG" value={num(V.apHdgBug)} unit="°" pad dn="hdgDown" up="hdgUp" />
<Sel label="ALT" value={num(V.apAltBug)} unit="FT" dn="altDown" up="altUp" />
<Sel label="VS" value={num(V.apVsBug)} unit="FPM" alias="apVsBug" step={100} />
<Sel label="IAS" value={num(V.apSpdBug)} unit="KT" alias="apSpdBug" step={1} />
</div>
<div className="afcs-pitch">
<span>PITCH</span>
<button className="apk sm" onClick={() => command('noseUp')}>NOSE&nbsp;UP</button>
<button className="apk sm" onClick={() => command('noseDown')}>NOSE&nbsp;DN</button>
</div>
</div>
</div>
);
}
+225
View File
@@ -0,0 +1,225 @@
import React, { useState } from 'react';
import { num } 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,
// the autopilot mode controller built into the left bezel (just like the sim).
//
// EVERY control is clickable and fires the matching real X-Plane command
// (sim/GPS/g1000n1_* on the PFD, g1000n3_* on the MFD) via xp.command().
//
// The PFD softkeys are a two-level menu, exactly like the real unit:
// root → [INSET · PFD · CDI · DME · XPDR · IDENT · TMR/REF · NRST · CAUTION]
// PFD → [PATHWAY · SYN TERR · HRZN HDG · APTSIGNS · … · BACK]
// 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'],
// 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).
xpdrcode: ['0', '1', '2', '3', '4', '5', '6', '7', 'BKSP', '', 'BACK', ''],
// INSET submenu: on/off, declutter, base layer, OFF, back.
inset: ['INSET', 'DCLTR', '', 'TOPO', 'TERRAIN', '', '', '', '', '', 'OFF', 'BACK'],
};
// MFD softkeys are a two-level menu like the real unit. MAP opens the Map-Opt
// 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', '', ''],
mapopt: ['TRAFFIC', 'PROFILE', 'TOPO', 'TERRAIN', 'AIRWAYS', '', 'NEXRAD', 'OSM', '', '', 'BACK', ''],
system: ['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, 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 menu = variant === 'mfd' ? MFD_MENU : PFD_MENU;
const keys = menu[page] || menu.root;
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 typeDigit = (d) => {
const next = (squawk + d).slice(-4);
setSquawk(next);
if (next.length === 4) { // 4 octal digits → write & exit
xp && xp.setDataref('xpdrCode', parseInt(next, 10));
setSquawk(''); setPage('xpdr');
}
};
const onSoftkey = (i, label) => {
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 === '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 === 'PFD') setPage('pfd');
else if (label === 'BACK') setPage(page === 'xpdrcode' ? 'xpdr' : 'root');
else if (label === 'SYN TERR') onToggleSvt && onToggleSvt();
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 === 'NRST') onToggleNrst && onToggleNrst();
else if (label === 'TMR/REF') onToggleTmr && onToggleTmr();
else if (label === 'XPDR') setPage('xpdr');
else if (label === 'STBY') setMode(1);
else if (label === 'ON') setMode(2);
else if (label === 'ALT') setMode(3);
else if (label === 'VFR') xp && xp.setDataref('xpdrCode', 1200);
else if (label === 'CODE') { setSquawk(''); setPage('xpdrcode'); }
else if (label === 'IDENT') xp && xp.command('xpdrIdent');
else if (label === 'BKSP') setSquawk((s) => s.slice(0, -1));
else if (page === 'xpdrcode' && /^[0-7]$/.test(label)) typeDigit(label);
}
};
// 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 === 'DCLTR' && mapMode?.dcltr > 0);
return (label === 'SYN TERR' && svt3d) || (label === 'INSET' && inset) || (label === 'NRST' && nrst) || (label === 'TMR/REF' && tmr)
|| (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')
|| (label === 'DCLTR' && insetMode?.dcltr > 0);
};
return (
<div className="bezel">
<div className="bezel-knobs left">
<Knob label="NAV" sub="VOL · PUSH ID" fire={fire}
outer={['nav_outer_up', 'nav_outer_down']} inner={['nav_inner_up', 'nav_inner_down']} push="nav12" />
<Knob label="HDG" sub="PUSH HDG SYNC" fire={fire}
outer={['hdg_up', 'hdg_down']} push="hdg_sync" />
{variant === 'mfd' && xp && <APController xp={xp} />}
<Knob label="ALT" sub="" big fire={fire}
outer={['alt_outer_up', 'alt_outer_down']} inner={['alt_inner_up', 'alt_inner_down']} />
</div>
<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="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>
))}
</div>
</div>
<div className="bezel-knobs right">
<Knob label="COM" sub="VOL · PUSH SQ" fire={fire}
outer={['com_outer_up', 'com_outer_down']} inner={['com_inner_up', 'com_inner_down']} push="com12" />
<Knob label="CRS / BARO" sub="PUSH CRS CTR" fire={fire}
outer={['crs_up', 'crs_down']} inner={['baro_up', 'baro_down']} push="crs_sync" />
<Knob label="RANGE" sub="PUSH PAN" joy fire={fire}
outer={['range_up', 'range_down']} push="pan_push" pan />
<div className="bezel-grid">
<BtnG fire={fire} cmd="direct" onClick={onDirect}>D</BtnG><BtnG fire={fire} cmd="menu">MENU</BtnG>
<BtnG fire={fire} cmd="fpl">FPL</BtnG><BtnG fire={fire} cmd="proc" onClick={onProc}>PROC</BtnG>
<BtnG fire={fire} cmd="clr">CLR</BtnG><BtnG fire={fire} cmd="ent">ENT</BtnG>
</div>
<Knob label="FMS" sub="PUSH CRSR" big fire={fire}
outer={['fms_outer_up', 'fms_outer_down']} inner={['fms_inner_up', 'fms_inner_down']} push="cursor" />
</div>
</div>
);
}
function BtnG({ fire, cmd, onClick, children }) {
return <button className="bezel-btn sm" onClick={() => { fire(cmd); onClick && onClick(); }}>{children}</button>;
}
// Autopilot mode controller (left bezel of the MFD). Buttons fire real X-Plane
// commands; active modes light up from autopilot_state / servos_on.
function APController({ xp }) {
const st = num(xp.values.apState);
const on = (bit) => (st & bit) !== 0;
const eng = num(xp.values.apEngaged) > 0;
const B = ({ label, cmd, active }) => (
<button className={`ap-key ${active ? 'on' : ''}`} onClick={() => xp.command(cmd)}>{label}</button>
);
return (
<div className="ap-controller">
<B label="AP" cmd="apToggle" active={eng} />
<B label="FD" cmd="fdToggle" active={on(AP_BITS.fd)} />
<B label="HDG" cmd="hdg" active={on(AP_BITS.hdg)} />
<B label="ALT" cmd="altHold" active={on(AP_BITS.altHold)} />
<B label="NAV" cmd="nav" active={on(AP_BITS.nav)} />
<B label="VNV" cmd="vnav" active={on(AP_BITS.vnav)} />
<B label="APR" cmd="apr" active={on(AP_BITS.apr)} />
<B label="BC" cmd="backCourse" active={on(AP_BITS.bc)} />
<B label="VS" cmd="vs" active={on(AP_BITS.vs)} />
<B label="FLC" cmd="flc" active={on(AP_BITS.flc)} />
<B label="NOSE UP" cmd="noseUp" />
<B label="NOSE DN" cmd="noseDown" />
</div>
);
}
// Concentric G1000 knob. The outer ring rotates via the side arrows ( ) and
// 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 }) {
const onWheel = (e) => {
if (!outer) return;
e.preventDefault();
const set = (e.shiftKey && inner) ? inner : outer;
fire(e.deltaY < 0 ? set[0] : set[1]);
};
return (
<div className={`knob-wrap ${big ? 'big' : ''}`}>
<span className="knob-lbl">{label}</span>
<div className="knob-cluster">
{inner && <button className="knob-arrow top" onClick={() => fire(inner[0])}>˄</button>}
{outer && <button className="knob-arrow left" onClick={() => fire(outer[1])}></button>}
<button
className={`knob outer ${joy ? 'joy' : ''}`}
onWheel={onWheel}
onClick={() => push && fire(push)}
title={push ? 'PUSH' : ''}
>
<span className="knob inner" />
{joy && <div className="joy-cross"></div>}
</button>
{outer && <button className="knob-arrow right" onClick={() => fire(outer[0])}></button>}
{inner && <button className="knob-arrow bottom" onClick={() => fire(inner[1])}>˅</button>}
</div>
{pan && (
<div className="pan-pad">
<button onClick={() => fire('pan_up')}></button>
<button onClick={() => fire('pan_left')}></button>
<button onClick={() => fire('pan_right')}></button>
<button onClick={() => fire('pan_down')}></button>
</div>
)}
{sub && <span className="knob-sub">{sub}</span>}
</div>
);
}
+135
View File
@@ -0,0 +1,135 @@
import React, { useState } from 'react';
import { num, navSearch } from '../api/useXplane.js';
// FMS as an X-Plane-style CDU/FMC: a green screen showing the active flight plan
// as legs, six line-select keys per side, a scratchpad, and an alphanumeric
// keypad. Edits go through the shared flight plan (the same one the PFD/MFD use).
//
// LSK (left, per row):
// • scratchpad has an ident → insert that waypoint at the row
// • DEL armed → delete the leg at the row
// • otherwise → make that leg the active (magenta) leg (Direct-To)
// EXEC exports the plan to X-Plane as an .fms file.
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 ROWS = 5; // legs visible per page
export default function CDU({ xp }) {
const { flightPlan, fp, exportMsg } = xp;
const wps = flightPlan.waypoints || [];
const active = Math.max(1, Math.min(wps.length - 1, flightPlan.activeLeg ?? 1));
const [scr, setScr] = useState('');
const [del, setDel] = useState(false);
const [msg, setMsg] = useState('');
const [page, setPage] = useState(0);
const pages = Math.max(1, Math.ceil((wps.length + 1) / ROWS));
const start = page * ROWS;
const type = (ch) => { setMsg(''); setScr((s) => (s + ch).slice(0, 8)); };
const clr = () => { if (scr) setScr((s) => s.slice(0, -1)); else { setDel(false); setMsg(''); } };
// resolve an ident and splice it into the plan at `index`
const insertAt = async (ident, index) => {
const hits = await navSearch(ident);
const hit = hits[0];
if (!hit) { setMsg('NOT IN DATABASE'); return; }
const next = wps.slice();
next.splice(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 });
setScr('');
};
const lsk = (rowIdx) => {
const i = start + rowIdx;
if (scr) { insertAt(scr, Math.min(i, wps.length)); return; }
if (del) { if (i < wps.length) fp.remove(i); setDel(false); return; }
if (i >= 1 && i < wps.length) fp.setActive(i);
};
const exec = () => { if (wps.length >= 2) fp.export('WEBFPL'); else setMsg('NEED 2 WAYPOINTS'); };
// build the visible rows
const rows = [];
for (let r = 0; r < ROWS; r++) {
const i = start + r;
if (i < wps.length) {
const w = wps[i], prev = wps[i - 1];
const d = prev ? distNm(prev, w) : 0;
const dtk = prev ? Math.round(brng(prev, w)) : null;
rows.push({ i, id: w.id, type: w.type, d, dtk, orig: i === 0, act: i === active });
} else if (i === wps.length) {
rows.push({ i, empty: true });
} else {
rows.push({ i, blank: true });
}
}
const A = 'ABCDEFGHIJKLMNOPQRSTUVWXYZ'.split('');
const KEYS = [A.slice(0, 7), A.slice(7, 14), A.slice(14, 21), A.slice(21, 26).concat([' ']), ['1', '2', '3', '4', '5'], ['6', '7', '8', '9', '0']];
const Lsk = ({ side, r }) => <button className={`cdu-lsk ${side}`} onClick={() => lsk(r)} aria-label={`LSK ${r + 1}${side}`} />;
return (
<div className="cdu">
<div className="cdu-unit">
<div className="cdu-screenwrap">
<div className="cdu-lsks left">{[0, 1, 2, 3, 4].map((r) => <Lsk key={r} side="L" r={r} />)}</div>
<div className="cdu-screen">
<div className="cdu-hdr">
<span>{del ? 'DELETE' : 'ACT FPL'}</span>
<span>{page + 1}/{pages}</span>
</div>
<div className="cdu-cols"><span>WPT</span><span>DTK</span><span>DIST</span></div>
{rows.map((row) => (
<div className={`cdu-row ${row.act ? 'act' : ''}`} key={row.i}>
{row.blank ? <span className="cdu-empty">·</span>
: row.empty ? <span className="cdu-add">&lt;------ ENTER WPT</span>
: (<>
<span className="cdu-wpt">{row.id}<i>{row.type}</i></span>
<span className="cdu-dtk">{row.dtk == null ? '---' : `${String(row.dtk).padStart(3, '0')}°`}</span>
<span className="cdu-dist">{row.orig ? 'ORIG' : `${row.d.toFixed(1)}`}</span>
</>)}
</div>
))}
<div className="cdu-scratch">
<span className="cdu-sp">{scr || (del ? 'DELETE—SEL LEG' : '')}</span>
{msg && <span className="cdu-msg">{msg}</span>}
{exportMsg && !msg && <span className="cdu-msg ok">{exportMsg.ok ? 'EXPORTED ✓' : exportMsg.error}</span>}
</div>
</div>
<div className="cdu-lsks right">{[0, 1, 2, 3, 4].map((r) => <Lsk key={r} side="R" r={r} />)}</div>
</div>
<div className="cdu-fn">
<button className="cdu-k fn" onClick={() => setPage((p) => Math.max(0, p - 1))}>PREV</button>
<button className="cdu-k fn" onClick={() => setPage((p) => Math.min(pages - 1, p + 1))}>NEXT</button>
<button className={`cdu-k fn ${del ? 'arm' : ''}`} onClick={() => { setDel((d) => !d); setScr(''); }}>DEL</button>
<button className="cdu-k fn" onClick={clr}>CLR</button>
<button className="cdu-k fn exec" onClick={exec}>EXEC</button>
</div>
<div className="cdu-pad">
{KEYS.map((rowK, ri) => (
<div className="cdu-padrow" key={ri}>
{rowK.map((k) => (
<button key={k} className="cdu-k" onClick={() => type(k === ' ' ? ' ' : k)}>{k === ' ' ? 'SP' : k}</button>
))}
</div>
))}
</div>
</div>
</div>
);
}
+91
View File
@@ -0,0 +1,91 @@
import React, { useEffect, useRef, useState } from 'react';
import { num, navSearch } from '../api/useXplane.js';
// G1000 Direct-To (D→) dialog. Type or pick a waypoint ident; ACTIVATE flies a
// direct magenta leg from the present position to it. We model that by setting
// the shared flight plan to [PPOS → target] (the map/HSI already draw the leg)
// and also firing the in-sim "direct" command so the real G1000 follows along.
const R_NM = 3440.065;
const rad = (d) => (d * Math.PI) / 180;
function distBrg(la1, lo1, la2, lo2) {
const dLat = rad(la2 - la1), dLon = rad(lo2 - lo1);
const a = Math.sin(dLat / 2) ** 2 + Math.cos(rad(la1)) * Math.cos(rad(la2)) * Math.sin(dLon / 2) ** 2;
const dist = 2 * R_NM * Math.asin(Math.min(1, Math.sqrt(a)));
const y = Math.sin(rad(lo2 - lo1)) * Math.cos(rad(la2));
const x = Math.cos(rad(la1)) * Math.sin(rad(la2)) - Math.sin(rad(la1)) * Math.cos(rad(la2)) * Math.cos(rad(lo2 - lo1));
const brg = (Math.atan2(y, x) * 180 / Math.PI + 360) % 360;
return { dist, brg };
}
export default function DirectTo({ xp, onClose }) {
const { values, fp, command } = xp;
const [entry, setEntry] = useState('');
const [hits, setHits] = useState([]);
const [sel, setSel] = useState(null); // chosen { id, lat, lon, type }
const inputRef = useRef(null);
useEffect(() => { inputRef.current?.focus(); }, []);
// Live ident search against X-Plane's nav database.
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 lat = num(values.lat), lon = num(values.lon);
const preview = sel && isFinite(lat) ? distBrg(lat, lon, sel.lat, sel.lon) : null;
const activate = () => {
if (!sel) return;
fp.set({ name: 'ACTIVE', waypoints: [
{ id: 'PPOS', lat, lon, type: 'USR' },
{ id: sel.id, lat: sel.lat, lon: sel.lon, type: sel.type || 'WPT' },
] });
command('direct'); // mirror to the in-sim G1000
onClose();
};
return (
<div className="dlg-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="dto-body">
<label className="dto-lbl">WAYPOINT</label>
<input
ref={inputRef}
className="dto-input"
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)"
autoCapitalize="characters" autoCorrect="off" spellCheck="false"
/>
{hits.length > 0 && (
<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>
))}
</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>
<div className="dlg-actions">
<button className="fbtn" onClick={onClose}>CANCEL</button>
<button className="fbtn add" disabled={!sel} onClick={activate}>ACTIVATE</button>
</div>
</div>
</div>
);
}
+121
View File
@@ -0,0 +1,121 @@
import React, { useState, useEffect } from 'react';
import { num, navSearch } from '../api/useXplane.js';
const R_NM = 3440.065;
const rad = (d) => (d * Math.PI) / 180;
const 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 bearing(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;
}
export default function FMS({ xp }) {
const { flightPlan, fp, values, exportMsg } = xp;
const wps = flightPlan.waypoints || [];
const [entry, setEntry] = useState('');
const [hits, setHits] = useState([]);
// live ident search against X-Plane's nav database
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 add = (id) => { fp.add(id || entry.trim()); setEntry(''); setHits([]); };
let total = 0;
const rows = wps.map((w, i) => {
const prev = wps[i - 1];
const d = prev ? distNm(prev, w) : 0;
const brg = prev ? bearing(prev, w) : null;
total += d;
return { w, i, d, brg };
});
const gs = num(values.groundspeed) * 1.94384;
const ete = gs > 20 ? total / gs : null; // hours
const active = Math.max(1, Math.min(wps.length - 1, flightPlan?.activeLeg ?? 1));
return (
<div className="fms">
<div className="fms-head">
<span>FLIGHT PLAN</span>
<span className="fms-total">
{total.toFixed(0)} NM{ete ? ` · ETE ${fmtHrs(ete)}` : ''}
</span>
</div>
<div className="fms-rows">
<div className="fms-row fms-colhead">
<span>#</span><span>WPT</span><span>DTK</span><span>DIST</span><span></span>
</div>
{rows.length === 0 && <div className="fms-empty">Kein Flugplan Wegpunkt eingeben oder auf der Map tippen.</div>}
{rows.map(({ w, i, d, brg }) => (
<div className={`fms-row ${i === 0 ? 'orig' : ''} ${i === active ? 'active' : ''}`} key={i}
onClick={() => i >= 1 && fp.setActive(i)} title={i >= 1 ? 'Als aktives Bein setzen' : ''}>
<span className="idx">{i + 1}</span>
<span className="wid">{w.id}<i className="wtype">{w.type}</i></span>
<span className="dtk">{brg == null ? '—' : `${String(Math.round(brg)).padStart(3, '0')}°`}</span>
<span className="dist">{i === 0 ? 'ORIG' : `${d.toFixed(1)}`}</span>
<button className="del" onClick={(e) => { e.stopPropagation(); fp.remove(i); }}></button>
</div>
))}
</div>
<div className="fms-scratch">
{hits.length > 0 && (
<div className="fms-hits">
{hits.map((h) => (
<button key={h.id + h.lat} onClick={() => add(h.id)}>
<b>{h.id}</b> <i>{h.type}</i> <span>{h.lat.toFixed(2)}, {h.lon.toFixed(2)}</span>
</button>
))}
</div>
)}
<div className="fms-input">
<input
value={entry}
onChange={(e) => setEntry(e.target.value.toUpperCase())}
onKeyDown={(e) => e.key === 'Enter' && add()}
placeholder="IDENT (z.B. KSEA, SEA) oder LAT,LON"
autoCapitalize="characters" autoCorrect="off" spellCheck="false"
/>
<button className="fbtn add" onClick={() => add()}>ADD</button>
</div>
<div className="fms-actions">
<button className="fbtn" onClick={() => fp.clear()} disabled={!wps.length}>CLEAR</button>
<button className="fbtn export" onClick={() => fp.export('WEBFPL')} disabled={wps.length < 2}>
EXPORT X-PLANE (.fms)
</button>
</div>
{exportMsg && (
<div className={`fms-export ${exportMsg.ok ? 'ok' : 'err'}`}>
{exportMsg.ok
? (exportMsg.intoXplane
? `✓ Gespeichert in X-Plane: ${shorten(exportMsg.file)} — im Flieger-FMS unter „Load" wählen.`
: `✓ Datei geschrieben: ${shorten(exportMsg.file)} (X-Plane-Ordner nicht gefunden — XPLANE_ROOT setzen).`)
: `${exportMsg.error}`}
</div>
)}
</div>
</div>
);
}
function fmtHrs(h) {
const m = Math.round(h * 60);
return `${Math.floor(m / 60)}:${String(m % 60).padStart(2, '0')}`;
}
function shorten(p) {
return p && p.length > 48 ? '…' + p.slice(-46) : p;
}
+210
View File
@@ -0,0 +1,210 @@
import React, { useState } from 'react';
import { num } from '../api/useXplane.js';
import MapView from './MapView.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);
// 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 [rangeNm, setRangeNm] = useState(8);
return (
<div className="mfd-g1000">
<MfdTopBar V={V} />
<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} />
</div>
</div>
</div>
);
}
/* ---------------- top NAV/COM bar ---------------- */
function MfdTopBar({ V }) {
const gs = Math.round(num(V.groundspeed) * 1.94384);
const trk = String(Math.round(num(V.track)) % 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">
<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 */}
<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>
{swap(150, 27)}
<text x="174" y="27" fill="#fff" fontSize="17">{navF(V.nav1Sb)}</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>
{/* 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>
<text x="378" y="27" fill="#0c9" fontSize="11">KT</text>
<text x="410" y="27" fill="#fff" fontSize="13">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>
{/* COM1 / COM2 */}
<text x="690" y="27" fill="#0f0" fontSize="17">{comF(V.com1)}</text>
{swap(818, 27)}
<rect x="846" y="11" width="92" height="21" fill="none" stroke="#0ff" strokeWidth="1.3" />
<text x="936" y="27" fill="#0ff" fontSize="17" textAnchor="end">{comF(V.com1Sb)}</text>
<text x="994" y="27" fill="#fff" fontSize="12" textAnchor="end">COM1</text>
<text x="690" y="58" fill="#fff" fontSize="17">{comF(V.com2)}</text>
<text x="936" y="58" fill="#fff" fontSize="17" textAnchor="end">{comF(V.com2Sb)}</text>
<text x="994" y="58" fill="#fff" fontSize="12" textAnchor="end">COM2</text>
</svg>
);
}
/* ---------------- engine instrument strip (EIS) ---------------- */
function EisStrip({ V }) {
const rpm = arr(V.engRpm);
const ffGph = (arr(V.fuelFlow) * 3600) / KG_PER_GAL;
const oilPsi = arr(V.oilPress);
const oilF = arr(V.oilTemp) * 9 / 5 + 32;
const egtF = arr(V.egt) * 9 / 5 + 32;
const fuelL = arr(V.fuelQty, 0) / KG_PER_GAL;
const fuelR = arr(V.fuelQty, 1) / KG_PER_GAL;
const volts = arr(V.volts, 0, 28);
const amps = arr(V.amps);
return (
<svg className="eis-svg" viewBox="0 0 190 540" preserveAspectRatio="xMidYMin meet" fontFamily="monospace">
<rect x="0" y="0" width="190" height="540" fill="#0a0a0a" />
<RpmArc rpm={rpm} />
<Bar y={132} label="FFLOW GPH" val={ffGph.toFixed(1)} min={0} max={20} value={ffGph}
zones={[{ from: 0, to: 17, c: '#0c0' }, { from: 17, to: 20, c: '#c00' }]} />
<Bar y={170} label="OIL PSI" val={Math.round(oilPsi)} min={0} max={100} value={oilPsi}
zones={[{ from: 0, to: 20, c: '#c00' }, { from: 20, to: 100, c: '#0c0' }]} />
<Bar y={208} label="OIL °F" val={Math.round(oilF)} min={75} max={250} value={oilF}
zones={[{ from: 100, to: 245, c: '#0c0' }]} />
<Bar y={246} label="EGT °F" val={Math.round(egtF)} min={800} max={1650} value={egtF} zones={[]} />
<Bar y={284} label="VAC" min={0} max={10} value={5}
zones={[{ from: 4.5, to: 5.5, c: '#0c0' }]} />
<FuelBar y={330} left={fuelL} right={fuelR} />
<text x="8" y="412" fill="#39d3c0" fontSize="12">ENG</text>
<text x="182" y="412" fill="#fff" fontSize="14" textAnchor="end">0.0 HRS</text>
<text x="95" y="438" fill="#39d3c0" fontSize="12" textAnchor="middle"> ELECTRICAL </text>
<text x="20" y="462" fill="#fff" fontSize="12">M</text>
<text x="95" y="462" fill="#39d3c0" fontSize="12" textAnchor="middle">BUS</text>
<text x="170" y="462" fill="#fff" fontSize="12" textAnchor="end">E</text>
<text x="18" y="482" fill="#fff" fontSize="15">{volts.toFixed(1)}</text>
<text x="95" y="482" fill="#39d3c0" fontSize="11" textAnchor="middle">VOLTS</text>
<text x="172" y="482" fill="#fff" fontSize="15" textAnchor="end">{volts.toFixed(1)}</text>
<text x="20" y="506" fill="#fff" fontSize="12">M</text>
<text x="95" y="506" fill="#39d3c0" fontSize="12" textAnchor="middle">BATT</text>
<text x="170" y="506" fill="#fff" fontSize="12" textAnchor="end">S</text>
<text x="18" y="526" fill="#fff" fontSize="15">{amps >= 0 ? '+' : ''}{amps.toFixed(1)}</text>
<text x="95" y="526" fill="#39d3c0" fontSize="11" textAnchor="middle">AMPS</text>
<text x="172" y="526" fill="#fff" fontSize="15" textAnchor="end">+0.0</text>
</svg>
);
}
function Bar({ y, label, val, min, max, value, zones }) {
const x0 = 8, x1 = 182, bw = x1 - x0;
const px = (v) => x0 + bw * Math.max(0, Math.min(1, (v - min) / (max - min)));
const p = px(value);
return (
<g>
<text x={x0} y={y} fill="#39d3c0" fontSize="12">{label}</text>
{val != null && <text x={x1} y={y} fill="#fff" fontSize="16" fontWeight="bold" textAnchor="end">{val}</text>}
<rect x={x0} y={y + 9} width={bw} height="5" fill="#2a2a2a" />
{zones.map((z, i) => <rect key={i} x={px(z.from)} y={y + 9} width={Math.max(0, px(z.to) - px(z.from))} height="5" fill={z.c} />)}
<polygon points={`${p},${y + 9} ${p - 5},${y + 1} ${p + 5},${y + 1}`} fill="#fff" stroke="#000" strokeWidth="0.5" />
</g>
);
}
// Fuel quantity: one bar per the C172's two tanks, with L and R pointers on a
// shared 01020F (gal) scale; yellow/red caution zone at the low end.
function FuelBar({ y, left, right }) {
const x0 = 8, x1 = 182, bw = x1 - x0, max = 26.5;
const px = (g) => x0 + bw * Math.max(0, Math.min(1, g / max));
const tick = (g, lbl) => (
<g key={lbl}>
<line x1={px(g)} y1={y + 16} x2={px(g)} y2={y + 20} stroke="#777" strokeWidth="1" />
<text x={px(g)} y={y + 31} fill="#aaa" fontSize="10" textAnchor="middle">{lbl}</text>
</g>
);
const ptr = (g, lbl) => (
<g>
<polygon points={`${px(g)},${y + 8} ${px(g) - 5},${y} ${px(g) + 5},${y}`} fill="#fff" stroke="#000" strokeWidth="0.5" />
<text x={px(g)} y={y - 2} fill="#fff" fontSize="9" textAnchor="middle">{lbl}</text>
</g>
);
return (
<g>
<text x={x0} y={y - 6} fill="#39d3c0" fontSize="12">FUEL QTY GAL</text>
<rect x={x0} y={y + 8} width={bw} height="6" fill="#2a2a2a" />
<rect x={px(0)} y={y + 8} width={px(2.5) - px(0)} height="6" fill="#c00" />
<rect x={px(2.5)} y={y + 8} width={px(5) - px(2.5)} height="6" fill="#dd0" />
<rect x={px(5)} y={y + 8} width={px(max) - px(5)} height="6" fill="#0c0" />
{tick(0, '0')}{tick(8.83, '10')}{tick(17.66, '20')}{tick(max, 'F')}
{ptr(left, 'L')}{ptr(right, 'R')}
</g>
);
}
function RpmArc({ rpm }) {
const max = 2700, frac = Math.max(0, Math.min(1, rpm / max));
const a0 = -210, a1 = 30, ang = a0 + (a1 - a0) * frac;
const cx = 95, cy = 62, r = 42;
const pt = (deg, rr) => [cx + rr * Math.cos((deg * Math.PI) / 180), cy + rr * Math.sin((deg * Math.PI) / 180)];
const arc = (s, e, color, w) => {
const [x0, y0] = pt(s, r), [x1, y1] = pt(e, r);
return <path d={`M${x0} ${y0} A${r} ${r} 0 ${e - s > 180 ? 1 : 0} 1 ${x1} ${y1}`} fill="none" stroke={color} strokeWidth={w} />;
};
const [nx, ny] = pt(ang, r - 2);
return (
<g fontFamily="monospace">
{arc(a0, a1, '#2a2a2a', 7)}
{arc(a0, -30, '#0c0', 7)}
{arc(0, a1, '#c00', 7)}
<line x1={cx} y1={cy} x2={nx} y2={ny} stroke="#fff" strokeWidth="2.5" />
<circle cx={cx} cy={cy} r="3" fill="#fff" />
<text x={cx} y={cy + 14} fill="#39d3c0" fontSize="12" textAnchor="middle">RPM</text>
<text x={cx} y={cy + 40} fill="#fff" fontSize="26" fontWeight="bold" textAnchor="middle">{Math.round(rpm)}</text>
</g>
);
}
/* ---------------- map chrome overlay (compass rose / range / mode) ---------------- */
const NICE = [0.5, 1, 1.5, 2, 2.5, 4, 5, 7.5, 10, 15, 20, 25, 40, 50, 75, 100, 150, 200, 250, 500];
function niceRange(nm) { let r = NICE[0]; for (const s of NICE) if (nm >= s) r = s; return 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>);
}
}
return (
<div className="map-chrome">
<svg className="map-rose" viewBox="0 0 320 320">{ticks}</svg>
<div className="mc-tr"><b>{gs} KT</b><span>NORTH UP</span></div>
<div className="mc-range">{rng} NM</div>
<div className="mc-mode">NAV <em className="on" /><em /><em /><em /><em /></div>
</div>
);
}
+208
View File
@@ -0,0 +1,208 @@
import React, { useEffect, useRef, useState } from 'react';
import L from 'leaflet';
import 'leaflet/dist/leaflet.css';
import { num } from '../api/useXplane.js';
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>';
// 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) {
const t = (f.type || '').toUpperCase();
let g, color;
if (t === 'APT') {
color = '#19d3ff';
g = `<circle cx='9' cy='9' r='6' fill='none' stroke='${color}' stroke-width='2'/><line x1='9' y1='2.5' x2='9' y2='15.5' stroke='${color}' stroke-width='1.4'/><line x1='2.5' y1='9' x2='15.5' y2='9' stroke='${color}' stroke-width='1.4'/>`;
} else if (t === 'VOR') {
color = '#19ff5a';
g = `<polygon points='9,2 15,5.5 15,12.5 9,16 3,12.5 3,5.5' fill='none' stroke='${color}' stroke-width='1.6'/><circle cx='9' cy='9' r='1.5' fill='${color}'/>`;
} else if (t === 'NDB') {
color = '#d59a5a';
g = `<circle cx='9' cy='9' r='6.5' fill='none' stroke='${color}' stroke-width='1.4' stroke-dasharray='2 2'/><circle cx='9' cy='9' r='1.4' fill='${color}'/>`;
} else {
color = '#cfe3ff';
g = `<polygon points='9,3 15,14.5 3,14.5' fill='none' stroke='${color}' stroke-width='1.4'/>`;
}
const lbl = label ? `<span class='nav-lbl' style='color:${color}'>${f.id}</span>` : '';
const html = `<div class='nav-sym'><svg viewBox='0 0 18 18' width='18' height='18'>${g}</svg>${lbl}</div>`;
return L.marker([f.lat, f.lon], {
icon: L.divIcon({ className: 'nav-divicon', html, iconSize: [18, 18], iconAnchor: [9, 9] }),
interactive: false,
});
}
// Selectable base layers — switched by the MFD's Map-Opt softkeys. 'dark' draws
// no tiles (pure black, like the G1000 with TOPO off); 'terrain' is a relief
// hillshade; 'topo' is shaded relief; 'osm' is our tuned street layer.
const TILES = {
topo: { url: 'https://{s}.tile.opentopomap.org/{z}/{x}/{y}.png', opts: { maxZoom: 17, subdomains: 'abc' } },
osm: { url: 'https://tile.openstreetmap.org/{z}/{x}/{y}.png', opts: { maxZoom: 19 } },
terrain: { url: 'https://server.arcgisonline.com/ArcGIS/rest/services/Elevation/World_Hillshade/MapServer/tile/{z}/{y}/{x}', opts: { maxZoom: 16 } },
dark: null,
};
export default function MapView({ values, flightPlan, fp, inset = false, hud = true, mapMode, dcltr = 0, onView }) {
const elRef = useRef(null);
const mapRef = useRef(null);
const acRef = useRef(null);
const routeRef = useRef(null);
const wpLayerRef = useRef(null);
const navLayerRef = useRef(null);
const navAbortRef = useRef(null);
const baseRef = useRef(null);
const [follow, setFollow] = useState(true);
const followRef = useRef(true);
followRef.current = follow;
const lat = num(values.lat, 47.45);
const lon = num(values.lon, -122.31);
const track = num(values.track);
const gs = num(values.groundspeed) * 1.94384; // m/s -> kt
const base = mapMode?.base || 'topo';
const dcltrRef = useRef(dcltr);
dcltrRef.current = dcltr;
// Swap the base tile layer (and report it via the container's dark class).
const applyBase = (map, name) => {
if (baseRef.current) { map.removeLayer(baseRef.current); baseRef.current = null; }
const def = TILES[name];
if (def) baseRef.current = L.tileLayer(def.url, def.opts).addTo(map);
if (baseRef.current) baseRef.current.bringToBack();
elRef.current?.classList.toggle('dark', name === 'dark');
};
// Report the current range (centre→top edge, in NM) for the G1000 range box.
const reportView = (map) => {
if (!onView) return;
const c = map.getCenter(), n = L.latLng(map.getBounds().getNorth(), c.lng);
onView({ rangeNm: map.distance(c, n) / 1852 });
};
// create map once
useEffect(() => {
const map = L.map(elRef.current, { zoomControl: !inset, attributionControl: false, dragging: !inset, scrollWheelZoom: !inset })
.setView([lat, lon], inset ? 10 : 9);
applyBase(map, base);
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);
// 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 () => {
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 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}`;
try {
navAbortRef.current?.abort();
navAbortRef.current = new AbortController();
const res = await fetch(url, { signal: navAbortRef.current.signal });
if (!res.ok) return;
const feats = await res.json();
layer.clearLayers();
const labels = z >= 8;
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('zoomend', () => reportView(map));
setTimeout(() => { refreshNav(); reportView(map); }, 300);
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);
// tap the map to drop a user waypoint (full map only)
if (!inset && fp) map.on('click', (e) => fp.addLatLon(+e.latlng.lat.toFixed(5), +e.latlng.lng.toFixed(5)));
if (!inset) map.on('dragstart', () => setFollow(false));
mapRef.current = map;
// leaflet needs a nudge once its container has real size
setTimeout(() => map.invalidateSize(), 200);
return () => { map.remove(); mapRef.current = null; };
}, []); // eslint-disable-line
// swap base layer when the MFD changes map mode
useEffect(() => {
const map = mapRef.current;
if (map) applyBase(map, base);
}, [base]); // eslint-disable-line
// declutter: hide nav symbology, or repopulate it, when the level changes
useEffect(() => {
const map = mapRef.current;
if (!map) return;
if (dcltr > 0) navLayerRef.current?.clearLayers();
else map.fire('moveend'); // triggers refreshNav to redraw symbols
}, [dcltr]); // eslint-disable-line
// update aircraft position + heading
useEffect(() => {
const ac = acRef.current, map = mapRef.current;
if (!ac || !map) return;
ac.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 });
}, [lat, lon, track]);
// redraw route + waypoints when the plan changes. Like the real G1000, the
// active leg (to waypoint `activeLeg`) is magenta; all other legs are white.
useEffect(() => {
const route = routeRef.current, layer = wpLayerRef.current;
if (!route || !layer) return;
route.clearLayers();
layer.clearLayers();
const wps = flightPlan?.waypoints || [];
const active = Math.max(1, Math.min(wps.length - 1, flightPlan?.activeLeg ?? 1));
for (let i = 1; i < wps.length; i++) {
const seg = [[wps[i - 1].lat, wps[i - 1].lon], [wps[i].lat, wps[i].lon]];
const isActive = i === active;
L.polyline(seg, { color: isActive ? '#ff20ff' : '#ffffff', weight: isActive ? 4 : 2.5, opacity: 0.95 }).addTo(route);
}
wps.forEach((w, i) => {
const isActiveWp = i === active; // the waypoint the active leg flies to
L.circleMarker([w.lat, w.lon], {
radius: 6, color: '#fff', weight: 2,
fillColor: isActiveWp ? '#ff20ff' : '#0a0a0a', fillOpacity: 1,
})
.bindTooltip(`${i + 1}. ${w.id}`, { permanent: true, direction: 'right', className: 'wp-label' })
.addTo(layer);
});
}, [flightPlan]);
if (inset) {
return (
<div className="mapwrap inset">
<div ref={elRef} className="leaflet-host" />
</div>
);
}
return (
<div className="mapwrap">
<div ref={elRef} className="leaflet-host" />
{hud && (
<>
<div className="map-hud">
<div className="hud-cell"><span>GS</span><b>{Math.round(gs)} kt</b></div>
<div className="hud-cell"><span>TRK</span><b>{String(Math.round(track) % 360).padStart(3, '0')}°</b></div>
<div className="hud-cell"><span>POS</span><b>{lat.toFixed(3)}, {lon.toFixed(3)}</b></div>
{num(values.gpsDistNm) > 0 && (
<div className="hud-cell"><span>WPT</span><b>{num(values.gpsDistNm).toFixed(1)} nm</b></div>
)}
</div>
<button className={`follow-btn ${follow ? 'on' : ''}`} onClick={() => setFollow((f) => !f)}>
{follow ? '◎ FOLLOW' : '◯ FOLLOW'}
</button>
<div className="map-hint">Tippe auf die Karte, um einen Wegpunkt hinzuzufügen</div>
</>
)}
</div>
);
}
+77
View File
@@ -0,0 +1,77 @@
import React, { useEffect, useRef, useState } from 'react';
import { num } from '../api/useXplane.js';
// The G1000 "Nearest" window. On the PFD it pops up over the right side when you
// press the NRST softkey; it lists the closest airports / VORs / NDBs to the
// aircraft with bearing + distance, straight from X-Plane's own nav data
// (/api/nav/nearest). Tabs switch the feature type, like turning the FMS knob
// through the NRST page group on the real unit.
const TABS = [
{ id: 'apt', label: 'APT' },
{ id: 'vor', label: 'VOR' },
{ id: 'ndb', label: 'NDB' },
];
// VOR freq comes in 10 kHz units (11630 → 116.30); NDB in kHz (e.g. 350).
const freqStr = (f, type) => {
const n = num(f);
if (!n) return '';
return type === 'vor' ? (n / 100).toFixed(2) : String(n);
};
export default function Nearest({ values, onClose }) {
const [type, setType] = useState('apt');
const [rows, setRows] = useState([]);
const lastRef = useRef(null);
const lat = num(values.lat), lon = num(values.lon);
useEffect(() => {
let abort = new AbortController();
let timer;
const load = async () => {
if (!isFinite(lat) || !isFinite(lon) || (lat === 0 && lon === 0)) return;
try {
const r = await fetch(`/api/nav/nearest?lat=${lat}&lon=${lon}&type=${type}&count=9`, { signal: abort.signal });
if (r.ok) setRows(await r.json());
} catch { /* aborted / offline */ }
};
load();
// Refresh as the aircraft moves (cheap server scan).
timer = setInterval(load, 5000);
return () => { abort.abort(); clearInterval(timer); };
}, [type, Math.round(lat * 50), Math.round(lon * 50)]); // re-key on ~1nm moves
return (
<div className="nrst-window">
<div className="nrst-head">
<span className="nrst-title">NEAREST</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>
))}
</div>
</div>
);
}
+589
View File
@@ -0,0 +1,589 @@
import React, { useRef, useState, useLayoutEffect, Suspense, lazy } from 'react';
import { num } from '../api/useXplane.js';
import MapView from './MapView.jsx';
import Nearest from './Nearest.jsx';
import TimerRef from './TimerRef.jsx';
// Lazy-load the heavy WebGL terrain engine only when the PFD is shown.
const SVT = lazy(() => import('./SVT.jsx'));
// Garmin G1000 (GDU 1040) PFD as used by the default Cessna 172. Every field is
// driven by the same X-Plane datarefs the in-sim G1000 uses, so values match.
const W = 1000;
const H = 780;
const PITCH_PX = 9;
const arr0 = (v, d = 0) => (Array.isArray(v) ? num(v[0], d) : num(v, d));
const navF = (v) => (num(v) / 100).toFixed(2); // NAV: 11170 -> 111.70
const comF = (v) => (num(v) / 100).toFixed(3); // COM: 11810 -> 118.100
// Great-circle bearing + distance (nm) from aircraft to a point.
const D2R = Math.PI / 180, R2D = 180 / Math.PI, R_NM = 3440.065;
function brgDist(la1, lo1, la2, lo2) {
const dLat = (la2 - la1) * D2R, dLon = (lo2 - lo1) * D2R;
const a = Math.sin(dLat / 2) ** 2 + Math.cos(la1 * D2R) * Math.cos(la2 * 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(la2 * D2R);
const x = Math.cos(la1 * D2R) * Math.sin(la2 * D2R) - Math.sin(la1 * D2R) * Math.cos(la2 * D2R) * Math.cos(dLon);
const brg = (Math.atan2(y, x) * R2D + 360) % 360;
return { brg, dist };
}
// GPS guidance to the active flight-plan leg, computed from our own plan.
function activeNav(V, fp) {
const wps = fp?.waypoints || [];
if (wps.length < 1) return null;
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 { brg, dist } = brgDist(lat, lon, wp.lat, wp.lon);
const prev = wps[ai - 1];
const dtk = prev ? brgDist(prev.lat, prev.lon, wp.lat, wp.lon).brg : brg;
const gs = num(V.groundspeed) * 1.94384;
const ete = gs > 20 ? (dist / gs) * 3600 : null; // seconds
// Cross-track deviation from the prev→wp leg, for the GPS CDI (dots; full
// scale 2 nm enroute = 1 nm/dot). + = right of course → CDI bar deflects left.
let def = 0, xtk = 0;
if (prev) {
const leg = brgDist(prev.lat, prev.lon, lat, lon); // prev → aircraft
xtk = Math.asin(Math.sin(leg.dist / R_NM) * Math.sin((leg.brg - dtk) * D2R)) * R_NM;
def = Math.max(-2.5, Math.min(2.5, -xtk / 1.0));
}
return { id: wp.id, brg, dist, dtk, ete, xtk, def };
}
function fmtEte(s) {
if (s == null) return '--:--';
const m = Math.round(s / 60);
return `${Math.floor(m / 60)}:${String(m % 60).padStart(2, '0')}`;
}
// VNAV: nearest downstream waypoint with a lower altitude constraint, and the
// vertical speed required to meet it at the current groundspeed.
function vnavInfo(V, fp) {
const wps = fp?.waypoints || [];
const ai = Math.max(1, Math.min(wps.length - 1, fp?.activeLeg ?? 1));
const alt = num(V.altitude);
const lat = num(V.lat), lon = num(V.lon);
const gs = num(V.groundspeed) * 1.94384;
if (gs < 40 || (!lat && !lon)) return null;
let cum = 0, prevLat = lat, prevLon = lon;
for (let i = ai; i < wps.length; i++) {
cum += brgDist(prevLat, prevLon, wps[i].lat, wps[i].lon).dist;
prevLat = wps[i].lat; prevLon = wps[i].lon;
const tgt = num(wps[i].alt);
if (tgt > 0 && tgt < alt - 50) {
const tMin = (cum / gs) * 60;
const vsReq = tMin > 0 ? (tgt - alt) / tMin : 0;
return { wptId: wps[i].id, tgtAlt: tgt, dist: cum, vsReq };
}
}
return null;
}
// Attitude SYMBOLOGY clip (pitch ladder, roll arc, FD) — only the upper region,
// so the ladder doesn't bleed into the tapes/HSI.
const ATT = { x: W / 2 - 290, y: 270 - 175, w: 580, h: 385 };
// The synthetic terrain fills the WHOLE display below the radio bar, exactly
// like the real G1000 — the tapes and HSI sit translucently on top of it.
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 }) {
const wrapRef = useRef(null);
const svgRef = useRef(null);
const [box, setBox] = useState(null);
const [insetBox, setInsetBox] = useState(null);
// Map SVG-space regions to on-screen pixels, accounting for the SVG's
// letterboxing (xMidYMid meet) — used for both the SVT canvas and the inset.
useLayoutEffect(() => {
const measure = () => {
const svg = svgRef.current, wrap = wrapRef.current;
if (!svg || !wrap) return;
const r = svg.getBoundingClientRect(), wr = wrap.getBoundingClientRect();
const scale = Math.min(r.width / W, r.height / H);
const offX = (r.width - W * scale) / 2 + (r.left - wr.left);
const offY = (r.height - H * scale) / 2 + (r.top - wr.top);
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));
};
measure();
const ro = new ResizeObserver(measure);
if (svgRef.current) ro.observe(svgRef.current);
window.addEventListener('resize', measure);
return () => { ro.disconnect(); window.removeEventListener('resize', measure); };
}, []);
const nav = activeNav(V, flightPlan);
const vnav = vnavInfo(V, flightPlan);
return (
<div className="pfd-wrap" ref={wrapRef}>
{svt && box && (
<div className="svt-pos" style={box}>
<Suspense fallback={<div className="svt-fallback" />}><SVT values={V} /></Suspense>
</div>
)}
{inset && insetBox && (
<div className="pfd-inset" style={insetBox}>
<MapView values={V} flightPlan={flightPlan} fp={fp} inset
mapMode={insetMode} dcltr={insetMode?.dcltr || 0} />
</div>
)}
<svg ref={svgRef} className="g1000" viewBox={`0 0 ${W} ${H}`} preserveAspectRatio="xMidYMid meet">
<defs>
<linearGradient id="sky" x1="0" y1="0" x2="0" y2="1">
<stop offset="0" stopColor="#1f4fd6" /><stop offset="0.75" stopColor="#2f74ec" /><stop offset="1" stopColor="#3f88f2" />
</linearGradient>
<linearGradient id="ground" x1="0" y1="0" x2="0" y2="1">
<stop offset="0" stopColor="#8a6533" /><stop offset="0.45" stopColor="#6f4f27" /><stop offset="1" stopColor="#523819" />
</linearGradient>
</defs>
{!svt && <rect x="0" y="0" width={W} height={H} fill="#000" />}
<RadioBar V={V} />
{nav && <NavStatus nav={nav} />}
{vnav && <VnavBox vnav={vnav} />}
<Attitude V={V} svt={svt} />
<AirspeedTape V={V} />
<AltitudeTape V={V} />
<GlideSlope V={V} />
<HSI V={V} nav={nav} />
<HdgCrsBoxes V={V} nav={nav} />
<DataStrip V={V} />
</svg>
{nrst && <Nearest values={V} onClose={onCloseNrst} />}
{tmr && <TimerRef values={V} onClose={onCloseTmr} />}
</div>
);
}
/* ---------------- top NAV/COM radio bar ---------------- */
// 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 }) {
const swap = (x, y) => <text x={x} y={y} fill="#0ff" fontSize="17" fontFamily="monospace" textAnchor="middle">{SWAP}</text>;
return (
<g fontFamily="monospace">
<rect x="0" y="0" width={W} height="74" fill="#000" />
{/* cell dividers */}
{[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) */}
<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>
{swap(176, 28)}
<text x="206" y="28" fill="#fff" fontSize="19">{navF(V.nav1Sb)}</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>
{swap(176, 60)}
<text x="206" y="60" fill="#fff" fontSize="19">{navF(V.nav2Sb)}</text>
{/* centre: active leg + DIS/BRG */}
<text x="430" y="26" fill="#e040fb" fontSize="20" textAnchor="middle">{'→'}</text>
<line x1="360" y1="40" x2="500" y2="40" stroke="#e040fb" strokeWidth="2" />
<circle cx="652" cy="18" r="4" fill="none" stroke="#e040fb" strokeWidth="2" />
<text x="568" y="60" fill="#fff" fontSize="14">DIS</text>
<text x="640" y="60" fill="#fff" fontSize="16" textAnchor="end">{V.gpsDistNm != null ? num(V.gpsDistNm).toFixed(1) : '_._'}</text>
<text x="648" y="60" fill="#0c9" fontSize="13">NM</text>
<text x="676" y="60" fill="#fff" fontSize="14">BRG</text>
{/* COM1 / COM2 (right) */}
<text x="720" y="28" fill="#0f0" fontSize="19">{comF(V.com1)}</text>
{swap(848, 28)}
<rect x="876" y="11" width="100" height="22" fill="none" stroke="#0ff" strokeWidth="1.4" />
<text x="970" y="28" fill="#0ff" fontSize="19" textAnchor="end">{comF(V.com1Sb)}</text>
<text x={W - 4} y="28" fill="#fff" fontSize="13" textAnchor="end">COM1</text>
<text x="720" y="60" fill="#fff" fontSize="19">{comF(V.com2)}</text>
{swap(848, 60)}
<text x="970" y="60" fill="#fff" fontSize="19" textAnchor="end">{comF(V.com2Sb)}</text>
<text x={W - 4} y="60" fill="#fff" fontSize="13" textAnchor="end">COM2</text>
</g>
);
}
/* ---------------- attitude + flight director ---------------- */
function Attitude({ V, svt }) {
const pitch = num(V.pitch), roll = num(V.roll), slip = num(V.slip);
const fdP = num(V.fdPitch), fdR = num(V.fdRoll);
const cx = W / 2, cy = 270;
const off = pitch * PITCH_PX;
return (
<g>
<defs>
<clipPath id="att"><rect x={cx - 290} y={cy - 175} width={580} height={385} /></clipPath>
</defs>
<g clipPath="url(#att)">
<g transform={`rotate(${-roll} ${cx} ${cy})`}>
<g transform={`translate(0 ${off})`}>
{/* sky/ground only when SVT is off — otherwise the 3D terrain shows */}
{!svt && <rect x={cx - 800} y={cy - 1100} width={1600} height={1100} fill="url(#sky)" />}
{!svt && <rect x={cx - 800} y={cy} width={1600} height={1100} fill="url(#ground)" />}
{!svt && <rect x={cx - 800} y={cy - 1.5} width={1600} height={3} fill="#fff" />}
{pitchLadder(cx, cy)}
</g>
</g>
{/* flight director command bars (magenta) */}
<g transform={`translate(0 ${(pitch - fdP) * PITCH_PX}) rotate(${roll - fdR} ${cx} ${cy})`}>
<path d={`M${cx - 90} ${cy + 16} L${cx} ${cy - 6} L${cx + 90} ${cy + 16}`}
fill="none" stroke="#e040fb" strokeWidth="6" strokeLinejoin="round" />
</g>
</g>
{rollArc(cx, cy, roll, slip)}
{/* fixed aircraft reference (yellow) */}
<g stroke="#ffcc00" strokeWidth="6" fill="#111" strokeLinejoin="round">
<path d={`M${cx - 150} ${cy} h60 l16 20 h-76 z`} />
<path d={`M${cx + 150} ${cy} h-60 l-16 20 h76 z`} />
</g>
{/* flight path marker (green) — track/AOA based; offset approximated */}
{(() => {
const fpx = Math.max(-120, Math.min(120, (num(V.track) - num(V.heading)) * 6));
return (
<g transform={`translate(${fpx} 0)`} stroke="#19ff19" strokeWidth="3" fill="none">
<circle cx={cx} cy={cy} r={9} />
<line x1={cx - 9} y1={cy} x2={cx - 22} y2={cy} />
<line x1={cx + 9} y1={cy} x2={cx + 22} y2={cy} />
<line x1={cx} y1={cy - 9} x2={cx} y2={cy - 18} />
</g>
);
})()}
{!svt && <rect x={cx - 290} y={cy - 175} width={580} height={385} fill="none" stroke="#000" strokeWidth="2" />}
</g>
);
}
function pitchLadder(cx, cy) {
const m = [];
for (let d = -90; d <= 90; d += 2.5) {
if (d === 0) continue;
const y = cy - d * PITCH_PX;
const ten = d % 10 === 0, five = d % 5 === 0;
const half = ten ? 70 : five ? 40 : 22;
m.push(<line key={'l' + d} x1={cx - half} y1={y} x2={cx + half} y2={y} stroke="#fff" strokeWidth="2" />);
if (ten) m.push(
<g key={'t' + d} fill="#fff" fontSize="17" fontFamily="monospace">
<text x={cx - half - 8} y={y + 6} textAnchor="end">{Math.abs(d)}</text>
<text x={cx + half + 8} y={y + 6} textAnchor="start">{Math.abs(d)}</text>
</g>
);
}
return <g>{m}</g>;
}
function rollArc(cx, cy, roll, slip) {
const r = 165;
const ticks = [-60, -45, -30, -20, -10, 0, 10, 20, 30, 45, 60];
return (
<g>
{ticks.map((t) => {
const a = (t - 90) * (Math.PI / 180);
const big = t % 30 === 0 || t === 0;
const r2 = r - (big ? 18 : 11);
return <line key={t} 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="#fff" strokeWidth={big ? 3 : 2} />;
})}
<path d={`M${cx} ${cy - r - 16} l-11 -16 h22 z`} fill="#fff" />
<g transform={`rotate(${-roll} ${cx} ${cy})`}>
<path d={`M${cx} ${cy - r + 2} l-11 18 h22 z`} fill="#ffcc00" />
<rect x={cx - 16 + slip * 7} y={cy - r + 22} width={32} height={9} rx={2} fill="#ffcc00" stroke="#000" />
</g>
</g>
);
}
/* ---------------- airspeed tape ---------------- */
// 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);
const x = 60, top = 95, 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 = [];
const lo = Math.floor((ias - 50) / 10) * 10;
for (let s = lo; s <= ias + 50; s += 10) {
if (s < 0) continue;
const y = cy + (ias - s) * px;
ticks.push(<g key={s}><line x1={x + 48} y1={y} x2={x + 60} y2={y} stroke="#fff" strokeWidth="2" />
<text x={x + 42} y={y + 7} textAnchor="end" fill="#fff" fontSize="22" fontFamily="monospace">{s}</text></g>);
}
const yOf = (s) => Math.max(top, Math.min(top + h, cy + (ias - s) * px));
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;
return (
<g fontFamily="monospace">
<rect x={x} y={top} width={W2} height={h} fill="#0e1626c8" />
{/* V-speed colour strip (white flap arc, green normal, yellow caution, red Vne) */}
{band(33, 85, '#e8e8e8')}
{band(48, 129, '#16c116')}
{band(129, 163, '#e0d000')}
<rect x={sx} y={yOf(180)} width={7} height={Math.max(0, yOf(163) - yOf(180))} fill="#d01010" />
{ticks}
{/* 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>
{/* V-speed reference list below the tape */}
{VSPEEDS.map((v, i) => (
<g key={v.l}>
<text x={x + 40} y={top + h + 24 + i * 24} textAnchor="end" fill="#0ff" fontSize="18">{v.s}</text>
<rect x={x + 46} y={top + h + 10 + i * 24} width="18" height="18" fill="#0ff" />
<text x={x + 55} y={top + h + 24 + i * 24} textAnchor="middle" fill="#000" fontSize="15" fontWeight="bold">{v.l}</text>
</g>
))}
{/* TAS box at the very bottom */}
<rect x={x} y={top + h + 84} width={W2} height={26} fill="#000" stroke="#3a3a3a" />
<text x={x + 6} y={top + h + 103} fill="#0ff" fontSize="14">TAS</text>
<text x={x + W2 - 6} y={top + h + 103} textAnchor="end" fill="#fff" fontSize="16">{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);
const x = W - 70 - 84, W2 = 84, top = 95, h = 350, cy = top + h / 2, px = 0.42;
const ticks = [];
const lo = Math.floor((alt - 420) / 100) * 100;
for (let a = lo; a <= alt + 420; a += 100) {
const y = cy + (alt - a) * px;
ticks.push(<g key={a}><line x1={x + W2 - 18} y1={y} x2={x + W2 - 4} y2={y} stroke="#fff" strokeWidth="2" />
<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));
// 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.
const hi = Math.floor(alt / 100);
const STEP = 20, ROW = 25; // drum increment + row height
const base = Math.floor(alt / STEP) * STEP; // nearest 20 ft at/below current
const frac = (alt - base) / STEP; // 0..1 position between rows
const selStr = altBug > 0 ? String(Math.round(altBug)) : '- - - - -';
// drum geometry
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>
<rect x={x} y={top} width={W2} height={h} fill="#0e1626c8" />
{ticks}
{/* 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
+ a rolling tens/units drum that scrolls through 20-ft steps, so two
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">
{[-1, 0, 1, 2].map((k) => {
const v = base + k * STEP;
const s = String(((v % 100) + 100) % 100).padStart(2, '0');
return <text key={k} x={drumCx} y={cy + (frac - k) * ROW + 7} textAnchor="middle">{s}</text>;
})}
</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>
{/* VSI to the right */}
<VSI x={x + W2 + 34} cy={cy} h={h} vs={vs} bug={num(V.apVsBug)} />
</g>
);
}
function VSI({ x, cy, h, vs, bug }) {
const max = 2000, top = cy - h / 2 + 10, bot = cy + h / 2 - 10;
const yOf = (v) => cy - (Math.max(-max, Math.min(max, v)) / max) * (h / 2 - 10);
return (
<g fontFamily="monospace">
<rect x={x} y={top} width="30" height={bot - top} fill="#0e1626a0" />
{[2000, 1000, 0, -1000, -2000].map((v) => (
<g key={v}>
<line x1={x} y1={yOf(v)} x2={x + 8} y2={yOf(v)} stroke="#9aa" strokeWidth="2" />
{v !== 0 && <text x={x + 11} y={yOf(v) + 5} fill="#bbb" fontSize="13">{Math.abs(v) / 1000}</text>}
</g>
))}
{[500, 1500, -500, -1500].map((v) => <line key={v} x1={x} y1={yOf(v)} x2={x + 5} y2={yOf(v)} stroke="#778" strokeWidth="1.5" />)}
{bug !== 0 && <polygon points={`${x},${yOf(bug)} ${x + 12},${yOf(bug) - 6} ${x + 12},${yOf(bug) + 6}`} fill="#0ff" />}
<polygon points={`${x - 6},${yOf(vs)} ${x + 14},${yOf(vs) - 8} ${x + 30},${yOf(vs) - 8} ${x + 30},${yOf(vs) + 8} ${x + 14},${yOf(vs) + 8}`} fill="#000" stroke="#fff" strokeWidth="1.5" />
<text x={x + 28} y={yOf(vs) + 5} textAnchor="end" fill="#fff" fontSize="13">{Math.abs(vs) >= 100 ? Math.round(vs / 10) * 10 : ''}</text>
</g>
);
}
/* ---------------- HSI compass rose ---------------- */
function HSI({ V, nav }) {
const hdg = ((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);
const cx = W / 2, cy = 630, r = 130;
const ticks = [];
for (let d = 0; d < 360; d += 5) {
const a = ((d - hdg - 90) * Math.PI) / 180;
const big = d % 30 === 0;
const r2 = r - (big ? 18 : 10);
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="#fff" strokeWidth={big ? 2.5 : 1.5} />);
if (big) {
const lr = r - 36, la = a;
const lbl = d === 0 ? 'N' : d === 90 ? 'E' : d === 180 ? 'S' : d === 270 ? 'W' : d / 10;
ticks.push(<text key={'L' + d} x={cx + lr * Math.cos(la)} y={cy + lr * Math.sin(la) + 6}
textAnchor="middle" fill="#fff" fontSize="18" fontFamily="monospace">{lbl}</text>);
}
}
const bugA = bug - hdg, crsA = crs - hdg;
const defPx = Math.max(-2, Math.min(2, def)) * 26;
return (
<g>
<circle cx={cx} cy={cy} r={r + 6} fill="#000a" />
{ticks}
{/* lubber line + heading box */}
<polygon points={`${cx},${cy - r - 6} ${cx - 9},${cy - r - 22} ${cx + 9},${cy - r - 22}`} fill="#fff" />
<rect x={cx - 34} y={cy - r - 52} width={68} height={28} fill="#000" stroke="#fff" />
<text x={cx} y={cy - r - 31} textAnchor="middle" fill="#fff" fontSize="22" fontFamily="monospace">{String(Math.round(hdg) % 360).padStart(3, '0')}</text>
{/* heading bug (cyan) */}
<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) */}
<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" />
{/* CDI deviation bar */}
<line x1={cx + defPx} y1={cy - 42} x2={cx + defPx} y2={cy + 42} stroke="#e040fb" 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" />}
</g>
{/* cyan bearing pointer to the active flight-plan waypoint (BRG) */}
{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>
)}
<rect x={cx - 7} y={cy - 7} width={14} height={14} fill="#ffcc00" stroke="#000" strokeWidth="2" />
</g>
);
}
/* ---------------- VNAV box (descent target + required vertical speed) ---------------- */
function VnavBox({ vnav }) {
const vs = Math.round(vnav.vsReq / 10) * 10;
return (
<g fontFamily="monospace">
<rect x={W / 2 - 130} y={107} width={260} height={22} fill="#000a" stroke="#2a3a2a" rx="3" />
<text x={W / 2 - 122} y={123} fill="#39d3c0" fontSize="13" fontWeight="bold">VNV</text>
<text x={W / 2 - 88} y={123} fill="#fff" fontSize="14">{vnav.tgtAlt}<tspan fill="#9aa" fontSize="10">FT</tspan></text>
<text x={W / 2 - 14} y={123} fill="#9aa" fontSize="12">@{vnav.wptId}</text>
<text x={W / 2 + 56} y={123} fill="#9aa" fontSize="11">VS</text>
<text x={W / 2 + 80} y={123} fill="#fff" fontSize="14">{vs >= 0 ? '+' : ''}{vs}</text>
</g>
);
}
/* ---------------- glideslope (vertical deviation) for ILS approaches ---------------- */
// Localizer freqs: 108.10111.95 MHz with an odd 100-kHz digit → an ILS (has GS).
function isILS(navHz) {
const f = num(navHz) / 100;
return f >= 108.1 && f <= 111.95 && Math.floor(f * 10) % 2 === 1;
}
function GlideSlope({ V }) {
if (!isILS(V.nav1)) return null;
const cx = 792, cy = 280, h = 120, step = h / 2.5; // ±2.5 dots
// +vdef = above glideslope → diamond rides high → "fly down".
const def = Math.max(-2.5, Math.min(2.5, num(V.gsDef)));
const dy = cy - def * step;
return (
<g fontFamily="monospace">
<rect x={cx - 16} y={cy - h - 12} width={32} height={2 * h + 24} fill="#000a" rx="4" />
<text x={cx} y={cy - h - 2} textAnchor="middle" fill="#0f0" fontSize="13" fontWeight="bold">GS</text>
<line x1={cx} y1={cy} x2={cx} y2={cy} stroke="#fff" />
<line x1={cx - 12} y1={cy} x2={cx + 12} y2={cy} stroke="#fff" strokeWidth="2.5" />
{[-2, -1, 1, 2].map((d) => (
<circle key={d} cx={cx} cy={cy - d * step} r="3.5" fill="none" stroke="#fff" strokeWidth="1.5" />
))}
<polygon points={`${cx - 11},${dy} ${cx},${dy - 9} ${cx + 11},${dy} ${cx},${dy + 9}`}
fill="none" stroke="#e040fb" strokeWidth="3" />
</g>
);
}
/* ---------------- GPS nav status block (top, when a leg is active) ---------------- */
function NavStatus({ nav }) {
return (
<g fontFamily="monospace">
<rect x={W / 2 - 215} y={78} width={430} height={26} fill="#000a" stroke="#333" strokeWidth="1" rx="3" />
<text x={W / 2 - 205} y={97} fill="#e040fb" fontSize="17" fontWeight="bold">{nav.id}</text>
<text x={W / 2 - 70} y={97} fill="#9aa" fontSize="12">DTK</text>
<text x={W / 2 - 38} y={97} fill="#fff" fontSize="16">{String(Math.round(nav.dtk)).padStart(3, '0')}°</text>
<text x={W / 2 + 30} y={97} fill="#9aa" fontSize="12">DIS</text>
<text x={W / 2 + 62} y={97} fill="#fff" fontSize="16">{nav.dist.toFixed(1)}<tspan fill="#9aa" fontSize="11">nm</tspan></text>
<text x={W / 2 + 150} y={97} fill="#9aa" fontSize="12">ETE</text>
<text x={W / 2 + 182} y={97} fill="#fff" fontSize="16">{fmtEte(nav.ete)}</text>
</g>
);
}
/* ---------------- HDG/CRS boxes flanking the HSI ---------------- */
function HdgCrsBoxes({ V, nav }) {
const hdgSel = String(Math.round(num(V.apHdgBug)) % 360).padStart(3, '0');
// In GPS mode (active leg) the CRS field shows the desired track.
const crsVal = nav ? nav.dtk : num(V.obsCrs, 360);
const crs = String(Math.round(crsVal) % 360 || 360).padStart(3, '0');
return (
<g fontFamily="monospace">
<rect x="290" y="600" width="118" height="30" fill="#000c" stroke="#3a3a3a" strokeWidth="1" />
<text x="300" y="622" fill="#0ff" fontSize="15">HDG</text>
<text x="400" y="622" fill="#0ff" fontSize="20" textAnchor="end">{hdgSel}°</text>
<rect x="592" y="600" width="118" height="30" fill="#000c" stroke="#3a3a3a" strokeWidth="1" />
<text x="602" y="622" fill="#e040fb" fontSize="15">CRS</text>
<text x="702" y="622" fill="#e040fb" fontSize="20" textAnchor="end">{crs}°</text>
</g>
);
}
/* ---------------- bottom data line: OAT / ISA / XPDR / LCL ---------------- */
function DataStrip({ V }) {
const oatC = num(V.oat);
const oatF = Math.round(oatC * 9 / 5 + 32);
const stdC = 15 - 1.98 * (num(V.altitude) / 1000); // ISA temp at altitude
const isaF = Math.round((oatC - stdC) * 9 / 5);
const xpdr = String(num(V.xpdrCode, 1200)).padStart(4, '0');
const mode = ['OFF', 'STBY', 'ON', 'ALT', 'TEST'][num(V.xpdrMode)] || 'ALT';
const now = new Date();
const lcl = `${String(now.getHours()).padStart(2, '0')}:${String(now.getMinutes()).padStart(2, '0')}:${String(now.getSeconds()).padStart(2, '0')}`;
return (
<g fontFamily="monospace" fontSize="17">
<line x1="0" y1="730" x2={W} y2="730" stroke="#3a3a3a" strokeWidth="1.5" />
{/* OAT + ISA (left) */}
<rect x="14" y="742" width="118" height="26" fill="#000" stroke="#3a3a3a" />
<text x="22" y="761" fill="#fff">OAT {oatF}°F</text>
<rect x="136" y="742" width="120" height="26" fill="#000" stroke="#3a3a3a" />
<text x="144" y="761" fill="#fff">ISA {isaF >= 0 ? '+' : ''}{isaF}°F</text>
{/* XPDR + LCL (right) — code/mode in green text, like the real GDU */}
<text x="690" y="761" fill="#fff">XPDR</text>
<text x="752" y="761" fill="#19ff19" fontWeight="bold">{xpdr}{mode}</text>
<text x="858" y="761" fill="#fff">LCL {lcl}</text>
</g>
);
}
+116
View File
@@ -0,0 +1,116 @@
import React, { useEffect, useState } from 'react';
import { num } from '../api/useXplane.js';
// G1000 PROC dialog. Pick a destination/airport, a category (Departure / Arrival
// / Approach), then a procedure + transition; LOAD inserts the procedure's leg
// fixes into the active flight plan. Procedures come from X-Plane's own CIFP data
// via /api/nav/procs and /api/nav/proc (resolved to coordinates server-side).
const CATS = [
{ id: 'approach', label: 'APPROACH', key: 'approaches', t: 'approach' },
{ id: 'arrival', label: 'ARRIVAL', key: 'stars', t: 'star' },
{ id: 'departure', label: 'DEPARTURE', key: 'sids', t: 'sid' },
];
export default function Proc({ xp, onClose }) {
const { flightPlan, fp, values } = xp;
// Default airport: the plan's destination if it's an airport, else blank.
const wps = flightPlan?.waypoints || [];
const destGuess = [...wps].reverse().find((w) => w.type === 'APT')?.id || '';
const [icao, setIcao] = useState(destGuess);
const [query, setQuery] = useState(destGuess);
const [procs, setProcs] = useState(null);
const [err, setErr] = useState('');
const [cat, setCat] = useState('approach');
const [selProc, setSelProc] = useState(null); // { name, transitions }
const [selTrans, setSelTrans] = useState('');
const [legs, setLegs] = useState([]);
// Fetch the procedure summary whenever the airport changes.
useEffect(() => {
const id = icao.trim().toUpperCase();
if (id.length < 3) { setProcs(null); return; }
let alive = true;
setErr(''); setProcs(null); setSelProc(null); setSelTrans(''); setLegs([]);
fetch(`/api/nav/procs?icao=${id}`).then((r) => r.ok ? r.json() : Promise.reject(r.status))
.then((d) => { if (alive) setProcs(d); })
.catch(() => { if (alive) setErr(`keine Prozeduren für ${id}`); });
return () => { alive = false; };
}, [icao]);
// Preview the resolved legs when a procedure+transition is chosen.
useEffect(() => {
if (!procs || !selProc) { setLegs([]); return; }
const c = CATS.find((c) => c.id === cat);
let alive = true;
const t = encodeURIComponent(selTrans || '');
fetch(`/api/nav/proc?icao=${procs.icao}&type=${c.t}&name=${encodeURIComponent(selProc.name)}&trans=${t}`)
.then((r) => r.ok ? r.json() : []).then((d) => { if (alive) setLegs(d); });
return () => { alive = false; };
}, [procs, cat, selProc, selTrans]);
const catList = procs ? (procs[CATS.find((c) => c.id === cat).key] || []) : [];
const load = () => {
if (!legs.length) return;
const existing = wps.slice();
// Departures go to the front, arrivals/approaches to the end.
const merged = cat === 'departure' ? [...legs, ...existing] : [...existing, ...legs];
fp.set({ name: 'ACTIVE', waypoints: merged, activeLeg: cat === 'departure' ? 1 : existing.length || 1 });
onClose();
};
return (
<div className="dlg-backdrop" onClick={onClose}>
<div className="dlg proc" onClick={(e) => e.stopPropagation()}>
<div className="dlg-head">PROCEDURES</div>
<div className="proc-body">
<div className="proc-apt">
<label>APT</label>
<input value={query} onChange={(e) => setQuery(e.target.value.toUpperCase())}
onKeyDown={(e) => e.key === 'Enter' && setIcao(query)}
placeholder="ICAO (z.B. KSEA)" autoCapitalize="characters" autoCorrect="off" spellCheck="false" />
<button className="fbtn" onClick={() => setIcao(query)}>LOAD</button>
</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>
{catList.map((p) => (
<button key={p.name} className={selProc?.name === p.name ? 'on' : ''}
onClick={() => { setSelProc(p); setSelTrans(p.transitions[0] || ''); }}>{p.name}</button>
))}
{procs && catList.length === 0 && <div className="proc-empty">keine</div>}
</div>
<div className="proc-list">
<div className="proc-coltitle">TRANS</div>
{selProc?.transitions.map((t) => (
<button key={t} className={selTrans === t ? 'on' : ''} onClick={() => setSelTrans(t)}>{t}</button>
))}
{selProc && selProc.transitions.length === 0 && <div className="proc-empty"></div>}
</div>
<div className="proc-preview">
<div className="proc-coltitle">{legs.length} FIXES</div>
{legs.map((l, i) => (
<div key={l.id + i} className="proc-leg">
<b>{l.id}</b>{l.alt ? <u>{l.alt}ft</u> : null}
</div>
))}
</div>
</div>
</div>
<div className="dlg-actions">
<button className="fbtn" onClick={onClose}>CANCEL</button>
<button className="fbtn add" disabled={!legs.length} onClick={load}>LOAD FPL</button>
</div>
</div>
</div>
);
}
+177
View File
@@ -0,0 +1,177 @@
import React, { useEffect, useRef } from 'react';
import maplibregl from 'maplibre-gl';
import 'maplibre-gl/dist/maplibre-gl.css';
import { num } from '../api/useXplane.js';
// Synthetic Vision background: real-world 3D terrain (elevation tiles) rendered
// in WebGL, with the camera placed at the aircraft and oriented by heading and
// pitch. Bank (roll) is applied as a CSS rotation of the whole canvas. This is
// the SVT *concept* using real-world DEM data — not X-Plane's own scenery.
//
// Free public elevation tiles: AWS "terrarium" (no API key needed).
const STYLE = {
version: 8,
glyphs: 'https://fonts.openmaptiles.org/{fontstack}/{range}.pbf', // for runway-number labels
sources: {
dem: {
type: 'raster-dem',
tiles: ['https://s3.amazonaws.com/elevation-tiles-prod/terrarium/{z}/{x}/{y}.png'],
encoding: 'terrarium',
tileSize: 256,
maxzoom: 11, // coarser cap = far fewer tiles to fetch
attribution: 'Elevation: Mapzen/AWS',
},
},
layers: [
// background shows above the horizon = the sky
{ id: 'bg', type: 'background', paint: { 'background-color': '#4a93da' } },
{
id: 'relief',
type: 'color-relief',
source: 'dem',
paint: {
'color-relief-color': [
'interpolate', ['linear'], ['elevation'],
-50, '#3d6ea5', 0, '#2e6b3a', 300, '#5a8f3c', 800, '#9aa84a',
1500, '#b08f4e', 2500, '#8d6b4a', 3500, '#b9b0a6', 4500, '#ffffff',
],
},
},
{ id: 'hill', type: 'hillshade', source: 'dem', paint: { 'hillshade-exaggeration': 0.55 } },
],
terrain: { source: 'dem', exaggeration: 1.3 },
};
// Build runway surfaces (+ threshold number labels) from the bridge's runway
// list. Each runway becomes a ground-draped quad plus two rotated number tags.
function runwayGeoJSON(list) {
const feats = [];
for (const r of list) {
const midLat = (r.la1 + r.la2) / 2;
const mLat = 111320, mLon = 111320 * Math.cos((midLat * Math.PI) / 180);
const dx = (r.lo2 - r.lo1) * mLon, dy = (r.la2 - r.la1) * mLat;
const len = Math.hypot(dx, dy) || 1;
const hw = (r.w || 30) / 2;
const dLon = ((-dy / len) * hw) / mLon, dLat = ((dx / len) * hw) / mLat;
const c1 = [r.lo1 + dLon, r.la1 + dLat], c2 = [r.lo2 + dLon, r.la2 + dLat];
const c3 = [r.lo2 - dLon, r.la2 - dLat], c4 = [r.lo1 - dLon, r.la1 - dLat];
feats.push({ type: 'Feature', geometry: { type: 'Polygon', coordinates: [[c1, c2, c3, c4, c1]] }, properties: {} });
const brg = (Math.atan2(dx, dy) * 180 / Math.PI + 360) % 360;
feats.push({ type: 'Feature', geometry: { type: 'Point', coordinates: [r.lo1, r.la1] }, properties: { num: r.n1, rot: brg } });
feats.push({ type: 'Feature', geometry: { type: 'Point', coordinates: [r.lo2, r.la2] }, properties: { num: r.n2, rot: (brg + 180) % 360 } });
}
return { type: 'FeatureCollection', features: feats };
}
export default function SVT({ values }) {
const elRef = useRef(null);
const mapRef = useRef(null);
const dataRef = useRef(values);
dataRef.current = values;
useEffect(() => {
let map;
try {
map = new maplibregl.Map({
container: elRef.current,
style: STYLE,
center: [num(values.lon, -122.31), num(values.lat, 47.45)],
zoom: 11.5,
pitch: 72,
bearing: num(values.heading),
maxPitch: 76, // lower max pitch = nearer horizon = less distant terrain
pixelRatio: 1, // don't render at 2× on retina — big perf/bandwidth win
renderWorldCopies: false,
maxTileCacheSize: 40,
attributionControl: false,
interactive: false,
preserveDrawingBuffer: true,
fadeDuration: 0,
});
mapRef.current = map;
} catch (e) {
// WebGL unavailable → the CSS gradient fallback stays visible.
console.warn('SVT: WebGL init failed', e?.message);
return;
}
// Runways from X-Plane's nav data, draped on the terrain with their numbers.
let rwyTimer;
const addRunways = () => {
if (map.getSource('runways')) return;
map.addSource('runways', { type: 'geojson', data: { type: 'FeatureCollection', features: [] } });
map.addLayer({ id: 'rwy-fill', type: 'fill', source: 'runways', filter: ['==', ['geometry-type'], 'Polygon'], paint: { 'fill-color': '#33373b', 'fill-opacity': 0.9 } });
map.addLayer({ id: 'rwy-line', type: 'line', source: 'runways', filter: ['==', ['geometry-type'], 'Polygon'], paint: { 'line-color': '#e8edf2', 'line-width': 1.6 } });
map.addLayer({
id: 'rwy-num', type: 'symbol', source: 'runways', filter: ['==', ['geometry-type'], 'Point'],
layout: {
'text-field': ['get', 'num'], 'text-font': ['Open Sans Bold'], 'text-size': 15,
'text-rotate': ['get', 'rot'], 'text-rotation-alignment': 'map', 'text-keep-upright': false,
'text-allow-overlap': true, 'text-ignore-placement': true,
},
paint: { 'text-color': '#fff', 'text-halo-color': '#000', 'text-halo-width': 1.4 },
});
let last = null;
const refresh = async () => {
const v = dataRef.current, lat = num(v.lat), lon = num(v.lon);
if (!isFinite(lat) || !isFinite(lon)) return;
if (last && Math.abs(last[0] - lat) < 0.02 && Math.abs(last[1] - lon) < 0.02) return;
last = [lat, lon];
try {
const res = await fetch(`/api/nav/runways?lat=${lat}&lon=${lon}&radius=15`);
if (!res.ok) return;
map.getSource('runways')?.setData(runwayGeoJSON(await res.json()));
} catch { /* offline */ }
};
refresh();
rwyTimer = setInterval(refresh, 4000);
};
// Terrain awareness (TAWS): recolour the relief relative to aircraft
// altitude — terrain within 1000 ft below = yellow, within 100 ft below or
// above = red, otherwise normal. Stops are in metres (terrarium elevation).
let lastBandM = null;
const updateTerrainAwareness = (altFt) => {
const altM = altFt * 0.3048;
if (lastBandM != null && Math.abs(altM - lastBandM) < 12) return;
lastBandM = altM;
const yellowLo = altM - 305, redLo = altM - 30; // 1000 ft / 100 ft below
const s = []; // [elevation, color] pairs, strictly increasing inputs
const push = (e, c) => { if (!s.length || e > s[s.length - 2]) s.push(e, c); };
push(-150, '#2f6a3c'); push(150, '#4f8a3e'); push(900, '#9a8a4a');
push(yellowLo - 1, '#7d6a3a');
push(yellowLo, '#e6c200'); push(redLo - 1, '#e6c200');
push(redLo, '#e03030'); push(redLo + 4000, '#ff2a2a');
try { map.setPaintProperty('relief', 'color-relief-color', ['interpolate', ['linear'], ['elevation'], ...s]); } catch { /* not ready */ }
};
let raf;
const tick = () => {
const v = dataRef.current;
// Keep the view close: higher zoom floor + capped pitch bounds the area.
const zoom = Math.max(10.5, Math.min(12.5, 12.5 - num(v.altitude) / 3500));
try {
map.jumpTo({
center: [num(v.lon, -122.31), num(v.lat, 47.45)],
bearing: num(v.heading),
pitch: Math.max(58, Math.min(76, 72 + num(v.pitch))),
zoom,
});
updateTerrainAwareness(num(v.altitude, 5500));
} catch { /* style not ready yet */ }
raf = requestAnimationFrame(tick);
};
map.on('load', () => { addRunways(); raf = requestAnimationFrame(tick); });
return () => { cancelAnimationFrame(raf); clearInterval(rwyTimer); map.remove(); mapRef.current = null; };
}, []); // eslint-disable-line
// Bank: rotate the whole terrain canvas opposite to aircraft roll; scale up so
// the corners stay covered while rotated.
const roll = num(values.roll);
return (
<div className="svt-fallback">
<div ref={elRef} className="svt-canvas" style={{ transform: `rotate(${-roll}deg) scale(1.5)` }} />
</div>
);
}
+90
View File
@@ -0,0 +1,90 @@
import React, { useEffect, useRef, useState } from 'react';
import { num } from '../api/useXplane.js';
// G1000 TMR/REF window (PFD). The real unit shows a generic timer plus the
// reference V-speeds and barometric minimums. This implements the timer
// (count-up or count-down with START/STOP/RESET) and the V-speed / minimums
// references with simple on/off bugs. Self-contained — no sim dependency.
const VSPEEDS = [
{ key: 'vr', label: 'Vr', def: 55 },
{ key: 'vx', label: 'Vx', def: 62 },
{ key: 'vy', label: 'Vy', def: 74 },
{ key: 'vg', label: 'Vg', def: 68 }, // best glide
];
function fmt(sec) {
const s = Math.max(0, Math.floor(sec));
const h = Math.floor(s / 3600), m = Math.floor((s % 3600) / 60), ss = s % 60;
const pad = (n) => String(n).padStart(2, '0');
return h > 0 ? `${pad(h)}:${pad(m)}:${pad(ss)}` : `${pad(m)}:${pad(ss)}`;
}
export default function TimerRef({ values, onClose }) {
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)
const tickRef = useRef(null);
useEffect(() => {
if (!running) return;
const t0 = Date.now() - elapsed * 1000;
tickRef.current = setInterval(() => setElapsed((Date.now() - t0) / 1000), 250);
return () => clearInterval(tickRef.current);
}, [running]); // eslint-disable-line
const shown = dir === 'dn' ? Math.max(0, target - elapsed) : elapsed;
const alt = num(values.altitude);
const belowMins = minsOn && alt > 0 && alt <= mins;
const reset = () => { setRunning(false); setElapsed(0); };
return (
<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>
<div className="tmr-dir">
<button className={dir === 'up' ? 'on' : ''} onClick={() => { setDir('up'); }}>UP</button>
<button className={dir === 'dn' ? 'on' : ''} onClick={() => { setDir('dn'); }}>DN</button>
</div>
<div className="tmr-ctl">
<button className="fbtn add" onClick={() => setRunning((r) => !r)}>{running ? 'STOP' : 'START'}</button>
<button className="fbtn" onClick={reset}>RESET</button>
</div>
{dir === 'dn' && (
<div className="tmr-target">
<label>FROM</label>
<button onClick={() => setTarget((t) => Math.max(60, t - 60))}></button>
<span>{fmt(target)}</span>
<button onClick={() => setTarget((t) => t + 60)}></button>
</div>
)}
<div className="tmr-sec">REFERENCES V-SPEEDS</div>
<div className="tmr-vspeeds">
{VSPEEDS.map((v) => (
<button key={v.key} className={vbugs[v.key] ? 'on' : ''}
onClick={() => setVbugs((b) => ({ ...b, [v.key]: !b[v.key] }))}>
<i>{v.label}</i><b>{v.def}</b><u>KT</u>
</button>
))}
</div>
<div className="tmr-mins">
<button className={minsOn ? 'on' : ''} onClick={() => setMinsOn((m) => !m)}>MINIMUMS</button>
<button onClick={() => setMins((m) => Math.max(0, m - 100))}></button>
<span className={belowMins ? 'alert' : ''}>{mins} FT</span>
<button onClick={() => setMins((m) => m + 100)}></button>
</div>
{belowMins && <div className="tmr-minalert">MINIMUMS</div>}
</div>
</div>
);
}
+304
View File
@@ -0,0 +1,304 @@
import React from 'react';
import { num } from '../api/useXplane.js';
// Classic analog "six-pack" VFR panel: airspeed, attitude, altimeter, turn
// coordinator, heading indicator, vertical speed — round steam gauges driven by
// the same X-Plane datarefs. For steam/VFR aircraft (and just because it looks
// great). Each gauge is a self-contained SVG on a dark instrument panel.
const arr0 = (v, d = 0) => (Array.isArray(v) ? num(v[0], d) : num(v, d));
const clamp = (v, lo, hi) => Math.max(lo, Math.min(hi, v));
// point on a dial: ang in degrees, 0 = up (12 o'clock), clockwise positive.
const pt = (cx, cy, r, ang) => {
const a = (ang - 90) * Math.PI / 180;
return [cx + r * Math.cos(a), cy + r * Math.sin(a)];
};
function Bezel({ title, children }) {
return (
<div className="vfr-gauge">
<svg viewBox="0 0 200 200">
<circle cx="100" cy="100" r="99" fill="#0c0d0f" />
<circle cx="100" cy="100" r="95" fill="#161a1f" stroke="#2a2f36" strokeWidth="2" />
<circle cx="100" cy="100" r="88" fill="#08090b" stroke="#000" strokeWidth="1" />
{children}
</svg>
<span className="vfr-name">{title}</span>
</div>
);
}
const Needle = ({ ang, len = 70, w = 4, color = '#fff', tail = 14 }) => (
<g transform={`rotate(${ang} 100 100)`}>
<line x1="100" y1={100 + tail} x2="100" y2={100 - len} stroke={color} strokeWidth={w} strokeLinecap="round" />
</g>
);
// generic tick ring
function ticks(min, max, a0, a1, step, big = 1, r = 84, lab) {
const out = [];
let i = 0;
for (let v = min; v <= max + 1e-6; v += step, i++) {
const ang = a0 + ((v - min) / (max - min)) * (a1 - a0);
const isBig = i % big === 0;
const [x1, y1] = pt(100, 100, r, ang);
const [x2, y2] = pt(100, 100, r - (isBig ? 12 : 7), ang);
out.push(<line key={'t' + v} x1={x1} y1={y1} x2={x2} y2={y2} stroke="#cfd6dd" strokeWidth={isBig ? 2 : 1} />);
if (lab && isBig) {
const [lx, ly] = pt(100, 100, r - 24, ang);
out.push(<text key={'l' + v} x={lx} y={ly + 4} textAnchor="middle" fill="#e7edf2" fontSize="12" fontFamily="'Saira Condensed',monospace">{lab(v)}</text>);
}
}
return out;
}
/* ---------- Airspeed ---------- */
function ASI({ V }) {
const kt = num(V.airspeed);
const A0 = -150, A1 = 150, MIN = 0, MAX = 200;
const ang = A0 + (clamp(kt, MIN, MAX) - MIN) / (MAX - MIN) * (A1 - A0);
const arc = (lo, hi, color, rr, wdt) => {
const [x1, y1] = pt(100, 100, rr, A0 + (lo / MAX) * (A1 - A0));
const [x2, y2] = pt(100, 100, rr, A0 + (hi / MAX) * (A1 - A0));
const large = ((hi - lo) / MAX) * 300 > 180 ? 1 : 0;
return <path d={`M${x1} ${y1} A${rr} ${rr} 0 ${large} 1 ${x2} ${y2}`} fill="none" stroke={color} strokeWidth={wdt} />;
};
return (
<Bezel title="AIRSPEED">
{arc(33, 85, '#fff', 70, 4)} {/* white flap arc */}
{arc(48, 129, '#21d04a', 78, 5)} {/* green normal */}
{arc(129, 163, '#e6c200', 78, 5)}{/* yellow caution */}
{(() => { const [x, y] = pt(100, 100, 78, A0 + (163 / MAX) * (A1 - A0)); const [x2, y2] = pt(100, 100, 70, A0 + (163 / MAX) * (A1 - A0)); return <line x1={x} y1={y} x2={x2} y2={y2} stroke="#e23" strokeWidth="4" />; })()}
{ticks(0, 200, A0, A1, 10, 2, 84, (v) => (v % 20 === 0 && v >= 40 ? v : ''))}
<text x="100" y="150" textAnchor="middle" fill="#9aa" fontSize="11" fontFamily="monospace">KT</text>
<Needle ang={ang} />
<circle cx="100" cy="100" r="7" fill="#ddd" />
</Bezel>
);
}
/* ---------- Attitude ---------- */
function AI({ V }) {
const pitch = num(V.pitch), roll = num(V.roll);
const PPD = 2.0; // px per degree pitch
const off = clamp(pitch, -25, 25) * PPD;
return (
<Bezel title="ATTITUDE">
<defs>
<clipPath id="aiclip"><circle cx="100" cy="100" r="86" /></clipPath>
</defs>
<g clipPath="url(#aiclip)">
<g transform={`rotate(${-roll} 100 100)`}>
<g transform={`translate(0 ${off})`}>
<rect x="-60" y="-120" width="320" height="220" fill="#4a90d9" />
<rect x="-60" y="100" width="320" height="220" fill="#7a5230" />
<line x1="-60" y1="100" x2="260" y2="100" stroke="#fff" strokeWidth="2" />
{[-20, -10, 10, 20].map((d) => (
<g key={d}>
<line x1={100 - (d % 20 === 0 ? 26 : 16)} y1={100 - d * PPD} x2={100 + (d % 20 === 0 ? 26 : 16)} y2={100 - d * PPD} stroke="#fff" strokeWidth="1.5" />
{d % 20 === 0 && <text x={100 - 34} y={100 - d * PPD + 4} fill="#fff" fontSize="9" textAnchor="middle">{Math.abs(d)}</text>}
</g>
))}
</g>
</g>
{/* bank arc + pointer */}
<g transform={`rotate(${-roll} 100 100)`}>
{[-60, -30, -20, -10, 0, 10, 20, 30, 60].map((b) => { const [x1, y1] = pt(100, 100, 84, b); const [x2, y2] = pt(100, 100, 78, b); return <line key={b} x1={x1} y1={y1} x2={x2} y2={y2} stroke="#fff" strokeWidth={b % 30 === 0 ? 2 : 1} />; })}
<polygon points="100,16 95,26 105,26" fill="#fff" />
</g>
</g>
{/* fixed aircraft reference */}
<polygon points="100,12 94,22 106,22" fill="#ffb300" />
<path d="M60 100 h22 l0 8 M140 100 h-22 l0 8" fill="none" stroke="#ffb300" strokeWidth="4" />
<rect x="97" y="97" width="6" height="6" fill="#ffb300" />
</Bezel>
);
}
/* ---------- Altimeter (3-pointer) ---------- */
function ALT({ V }) {
const alt = num(V.altitude), baro = num(V.baro, 29.92);
const a100 = (alt % 1000) / 1000 * 360;
const a1000 = (alt % 10000) / 10000 * 360;
const a10000 = (alt % 100000) / 100000 * 360;
return (
<Bezel title="ALTITUDE">
{ticks(0, 1000, 0, 360, 100, 1, 84, (v) => (v < 1000 ? v / 100 : ''))}
{ticks(0, 1000, 0, 360, 20, 5, 84)}
<rect x="118" y="92" width="42" height="16" fill="#000" stroke="#444" rx="2" />
<text x="139" y="104" textAnchor="middle" fill="#fff" fontSize="11" fontFamily="monospace">{baro.toFixed(2)}</text>
{/* 10000 ft thin pointer */}
<Needle ang={a10000} len={80} w={2} color="#ccc" tail={6} />
{/* 1000 ft short fat */}
<Needle ang={a1000} len={48} w={7} color="#fff" tail={10} />
{/* 100 ft long */}
<Needle ang={a100} len={78} w={4} color="#fff" tail={14} />
<circle cx="100" cy="100" r="7" fill="#ddd" />
</Bezel>
);
}
/* ---------- Turn coordinator ---------- */
function TC({ V }) {
const roll = num(V.roll), slip = num(V.slip);
const bank = clamp(roll, -30, 30); // little-plane bank (approx turn rate)
const ballX = 100 + clamp(slip, -8, 8) * 3.0;
return (
<Bezel title="TURN COORD.">
{/* standard-rate marks */}
{[-25, 25].map((b) => { const [x1, y1] = pt(100, 100, 80, b); const [x2, y2] = pt(100, 100, 66, b); return <line key={b} x1={x1} y1={y1} x2={x2} y2={y2} stroke="#fff" strokeWidth="2" />; })}
<text x="42" y="96" fill="#fff" fontSize="11">L</text>
<text x="150" y="96" fill="#fff" fontSize="11">R</text>
{/* little airplane */}
<g transform={`rotate(${bank} 100 100)`}>
<line x1="40" y1="100" x2="160" y2="100" stroke="#fff" strokeWidth="4" />
<line x1="100" y1="100" x2="100" y2="78" stroke="#fff" strokeWidth="4" />
<circle cx="100" cy="100" r="5" fill="#fff" />
</g>
<text x="100" y="150" textAnchor="middle" fill="#9aa" fontSize="9">2 MIN</text>
{/* inclinometer (slip ball) */}
<path d="M78 168 a22 22 0 0 1 44 0" fill="none" stroke="#444" strokeWidth="1" />
<rect x="92" y="160" width="2" height="12" fill="#666" /><rect x="106" y="160" width="2" height="12" fill="#666" />
<circle cx={ballX} cy="167" r="5" fill="#cfd6dd" />
</Bezel>
);
}
/* ---------- Heading indicator ---------- */
function HI({ V }) {
const hdg = ((num(V.heading) % 360) + 360) % 360;
const card = [];
for (let d = 0; d < 360; d += 5) {
const big = d % 30 === 0;
const [x1, y1] = pt(100, 100, 84, d);
const [x2, y2] = pt(100, 100, 84 - (big ? 12 : 7), d);
card.push(<line key={d} x1={x1} y1={y1} x2={x2} y2={y2} stroke="#fff" strokeWidth={big ? 2 : 1} />);
if (big) {
const [lx, ly] = pt(100, 100, 62, d);
const lbl = d === 0 ? 'N' : d === 90 ? 'E' : d === 180 ? 'S' : d === 270 ? 'W' : d / 10;
card.push(<text key={'l' + d} x={lx} y={ly + 4} textAnchor="middle" fill="#fff" fontSize="13" fontFamily="'Saira Condensed',monospace">{lbl}</text>);
}
}
return (
<Bezel title="HEADING">
<g transform={`rotate(${-hdg} 100 100)`}>{card}</g>
{/* fixed aircraft */}
<line x1="100" y1="64" x2="100" y2="136" stroke="#ffb300" strokeWidth="3" />
<line x1="78" y1="100" x2="122" y2="100" stroke="#ffb300" strokeWidth="3" />
<polygon points="100,18 95,28 105,28" fill="#0ff" />
</Bezel>
);
}
/* ---------- Vertical speed ---------- */
function VSI({ V }) {
const vs = clamp(num(V.vspeed), -2000, 2000);
// 0 at 9 o'clock (270°), climb sweeps up (toward 0/up), descent down.
const ang = 270 + (vs / 2000) * 160; // -2000→110°, 0→270°, +2000→430°(=70°)
return (
<Bezel title="VERT SPEED">
{ticks(-2000, 2000, 110, 430, 500, 1, 84, (v) => Math.abs(v) / 1000)}
{ticks(-2000, 2000, 110, 430, 100, 5, 84)}
<text x="100" y="150" textAnchor="middle" fill="#9aa" fontSize="9">FPM x1000</text>
<text x="64" y="104" fill="#fff" fontSize="10">0</text>
<Needle ang={ang} len={78} w={3} />
<circle cx="100" cy="100" r="6" fill="#ddd" />
</Bezel>
);
}
/* ---------- small gauges for the engine/fuel cluster ---------- */
const KG_GAL = 2.72;
function SmallBezel({ title, children }) {
return (
<div className="vfr-sg">
<svg viewBox="0 0 120 120">
<circle cx="60" cy="60" r="59" fill="#0c0d0f" />
<circle cx="60" cy="60" r="56" fill="#141414" stroke="#2a2f36" strokeWidth="1.5" />
{children}
</svg>
<span className="vfr-sname">{title}</span>
</div>
);
}
// dual half-dial gauge: left needle (lower-left sweep) + right needle (lower-right)
function Dual({ title, l, r }) {
const sg = (v, min, max, a0, a1) => a0 + (clamp(v, min, max) - min) / (max - min) * (a1 - a0);
const sp = (cx, cy, rr, ang) => { const a = (ang - 90) * Math.PI / 180; return [cx + rr * Math.cos(a), cy + rr * Math.sin(a)]; };
const band = (lo, hi, min, max, a0, a1, color) => {
const [x1, y1] = sp(60, 60, 46, sg(lo, min, max, a0, a1));
const [x2, y2] = sp(60, 60, 46, sg(hi, min, max, a0, a1));
return <path d={`M${x1} ${y1} A46 46 0 0 1 ${x2} ${y2}`} fill="none" stroke={color} strokeWidth="3" />;
};
const La = sg(l.value, l.min, l.max, -150, -20), Ra = sg(r.value, r.min, r.max, 20, 150);
return (
<SmallBezel title={title}>
{l.green && band(l.green[0], l.green[1], l.min, l.max, -150, -20, '#21d04a')}
{r.green && band(r.green[0], r.green[1], r.min, r.max, 20, 150, '#21d04a')}
<text x="30" y="40" fill="#9aa" fontSize="8" textAnchor="middle">{l.tag}</text>
<text x="90" y="40" fill="#9aa" fontSize="8" textAnchor="middle">{r.tag}</text>
<g transform={`rotate(${La} 60 60)`}><line x1="60" y1="64" x2="60" y2="22" stroke="#fff" strokeWidth="2.5" strokeLinecap="round" /></g>
<g transform={`rotate(${Ra} 60 60)`}><line x1="60" y1="64" x2="60" y2="22" stroke="#fff" strokeWidth="2.5" strokeLinecap="round" /></g>
<circle cx="60" cy="60" r="5" fill="#ccc" />
</SmallBezel>
);
}
/* ---------- tachometer ---------- */
function Tach({ V }) {
const rpm = arr0(V.engRpm);
const A0 = -150, A1 = 150;
const ang = A0 + clamp(rpm, 0, 3500) / 3500 * (A1 - A0);
return (
<Bezel title="TACHOMETER">
{ticks(0, 3500, A0, A1, 500, 1, 84, (v) => v / 100)}
{(() => { const [x1, y1] = pt(100, 100, 84, A0 + 2700 / 3500 * (A1 - A0)); const [x2, y2] = pt(100, 100, 72, A0 + 2700 / 3500 * (A1 - A0)); return <line x1={x1} y1={y1} x2={x2} y2={y2} stroke="#e23" strokeWidth="3" />; })()}
{(() => { const [x1, y1] = pt(100, 100, 80, A0 + 2100 / 3500 * (A1 - A0)); const [x2, y2] = pt(100, 100, 80, A0 + 2600 / 3500 * (A1 - A0)); return <path d={`M${x1} ${y1} A80 80 0 0 1 ${x2} ${y2}`} fill="none" stroke="#21d04a" strokeWidth="4" />; })()}
<text x="100" y="128" textAnchor="middle" fill="#9aa" fontSize="10">RPM x100</text>
<text x="100" y="150" textAnchor="middle" fill="#cfd6dd" fontSize="11" fontFamily="monospace">{Math.round(rpm)}</text>
<Needle ang={ang} />
<circle cx="100" cy="100" r="7" fill="#ddd" />
</Bezel>
);
}
/* ---------- digital OAT / Volts plate ---------- */
function Clock({ V }) {
const oatC = num(V.oat), oatF = Math.round(oatC * 9 / 5 + 32);
const volts = arr0(V.volts, 28);
return (
<div className="vfr-clock">
<div className="vc-row"><b>{oatF}°F</b><span>O.A.T.</span></div>
<div className="vc-row"><b>{volts.toFixed(1)}</b><span>VOLT</span></div>
</div>
);
}
export default function VFR({ values: V }) {
const fuelL = arr0(V.fuelQty, 0) / KG_GAL, fuelR = (Array.isArray(V.fuelQty) ? num(V.fuelQty[1]) : 0) / KG_GAL;
const oilF = arr0(V.oilTemp) * 9 / 5 + 32, oilP = arr0(V.oilPress);
const egtF = arr0(V.egt) * 9 / 5 + 32, ffGph = (arr0(V.fuelFlow) * 3600) / KG_GAL;
const amps = arr0(V.amps);
return (
<div className="vfr-panel">
<div className="vfr-layout">
<div className="vfr-cluster">
<Clock V={V} />
<Dual title="FUEL QTY" l={{ value: fuelL, min: 0, max: 26, green: [5, 26], tag: 'L' }} r={{ value: fuelR, min: 0, max: 26, green: [5, 26], tag: 'R' }} />
<Dual title="OIL" l={{ value: oilF, min: 75, max: 250, green: [100, 245], tag: '°F' }} r={{ value: oilP, min: 0, max: 115, green: [25, 100], tag: 'PSI' }} />
<Dual title="EGT · FF" l={{ value: egtF, min: 800, max: 1650, tag: 'EGT' }} r={{ value: ffGph, min: 0, max: 20, green: [0, 17], tag: 'GPH' }} />
<Dual title="VAC · AMP" l={{ value: 5, min: 0, max: 10, green: [4.5, 5.5], tag: 'SUC' }} r={{ value: amps, min: -60, max: 60, green: [0, 60], tag: 'AMP' }} />
</div>
<div className="vfr-main">
<div className="vfr-grid">
<ASI V={V} /><AI V={V} /><ALT V={V} />
<TC V={V} /><HI V={V} /><VSI V={V} />
</div>
<div className="vfr-tach"><Tach V={V} /></div>
</div>
</div>
</div>
);
}