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:
@@ -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 UP</button>
|
||||
<button className="apk sm" onClick={() => command('noseDown')}>NOSE DN</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -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 0–7).
|
||||
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>
|
||||
);
|
||||
}
|
||||
@@ -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"><------ 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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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 0–10–20–F (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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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.10–111.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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user