PFD/cockpit polish + KAP140 autopilot + UI refinements
- PFD: full-screen 2D attitude, G1000 yellow+magenta chevron symbology, rAF 60fps horizon smoothing, translucent tapes, slimmer softkey bar, header fixes - Collapsible macOS-dark sidebar (Inter), VFR six-pack + engine cluster + tach - KAP140 autopilot on the analog page; GMC-710 AFCS tab - FMS rebuilt as an X-Plane-style CDU; PWA; settings panel (knob mode) Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
Generated
+1
-1
@@ -5900,7 +5900,7 @@ dependencies = [
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "xplane-cockpit"
|
name = "xplane-cockpit"
|
||||||
version = "0.1.3"
|
version = "0.1.4"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"local-ip-address",
|
"local-ip-address",
|
||||||
"serde",
|
"serde",
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
[package]
|
[package]
|
||||||
name = "xplane-cockpit"
|
name = "xplane-cockpit"
|
||||||
version = "0.1.3"
|
version = "0.1.4"
|
||||||
description = "Desktop launcher for the X-Plane G1000 web cockpit"
|
description = "Desktop launcher for the X-Plane G1000 web cockpit"
|
||||||
authors = ["karim"]
|
authors = ["karim"]
|
||||||
edition = "2021"
|
edition = "2021"
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
{
|
{
|
||||||
"$schema": "https://schema.tauri.app/config/2",
|
"$schema": "https://schema.tauri.app/config/2",
|
||||||
"productName": "X-Plane Cockpit",
|
"productName": "X-Plane Cockpit",
|
||||||
"version": "0.1.3",
|
"version": "0.1.4",
|
||||||
"identifier": "ch.kgva.xplanecockpit",
|
"identifier": "ch.kgva.xplanecockpit",
|
||||||
"build": {
|
"build": {
|
||||||
"frontendDist": "../ui"
|
"frontendDist": "../ui"
|
||||||
|
|||||||
+32
-4
@@ -45,9 +45,14 @@ export default function App() {
|
|||||||
const [navWide, setNavWide] = useState(() => localStorage.getItem('navWide') === '1');
|
const [navWide, setNavWide] = useState(() => localStorage.getItem('navWide') === '1');
|
||||||
const go = (id) => { setTab(id); history.replaceState(null, '', `#${id}`); };
|
const go = (id) => { setTab(id); history.replaceState(null, '', `#${id}`); };
|
||||||
const toggleNav = () => setNavWide((w) => { localStorage.setItem('navWide', w ? '0' : '1'); return !w; });
|
const toggleNav = () => setNavWide((w) => { localStorage.setItem('navWide', w ? '0' : '1'); return !w; });
|
||||||
|
// Knob interaction: 'arrows' (visible ˄‹›˅, touch-friendly) or 'zones' (click
|
||||||
|
// the knob face). Settable in the settings panel, remembered.
|
||||||
|
const [knobMode, setKnobMode] = useState(() => localStorage.getItem('knobMode') || 'arrows');
|
||||||
|
const [settings, setSettings] = useState(false);
|
||||||
|
const setKnob = (m) => { localStorage.setItem('knobMode', m); setKnobMode(m); };
|
||||||
// Synthetic-terrain (3D) vs. classic blue/brown attitude — toggled by the
|
// Synthetic-terrain (3D) vs. classic blue/brown attitude — toggled by the
|
||||||
// PFD → SYN TERR softkey, exactly like the real XPLANE 1000.
|
// PFD → SYN TERR softkey, exactly like the real XPLANE 1000.
|
||||||
const [svt3d, setSvt3d] = useState(true);
|
const [svt3d, setSvt3d] = useState(false);
|
||||||
// The PFD INSET map (bottom-left) is off by default and toggled by its softkey.
|
// The PFD INSET map (bottom-left) is off by default and toggled by its softkey.
|
||||||
const [inset, setInset] = useState(false);
|
const [inset, setInset] = useState(false);
|
||||||
// INSET map options (base layer + declutter), set from the INSET submenu.
|
// INSET map options (base layer + declutter), set from the INSET submenu.
|
||||||
@@ -82,6 +87,13 @@ export default function App() {
|
|||||||
</button>
|
</button>
|
||||||
))}
|
))}
|
||||||
</nav>
|
</nav>
|
||||||
|
<button className="snav-i sb-gear" onClick={() => setSettings(true)} title="Einstellungen">
|
||||||
|
<svg className="snav-ic" viewBox="0 0 22 22" width="22" height="22" fill="none" stroke="currentColor" strokeWidth="1.6" strokeLinecap="round" strokeLinejoin="round">
|
||||||
|
<circle cx="11" cy="11" r="3.2" />
|
||||||
|
<path d="M11 2.5v2M11 17.5v2M2.5 11h2M17.5 11h2M5 5l1.4 1.4M15.6 15.6L17 17M17 5l-1.4 1.4M6.4 15.6L5 17" />
|
||||||
|
</svg>
|
||||||
|
<span className="snav-lbl">Einstellungen</span>
|
||||||
|
</button>
|
||||||
<div className={`sb-conn ${connKind}`} title={connText}>
|
<div className={`sb-conn ${connKind}`} title={connText}>
|
||||||
<span className="dot" />
|
<span className="dot" />
|
||||||
<span className="snav-lbl">{connText}</span>
|
<span className="snav-lbl">{connText}</span>
|
||||||
@@ -90,7 +102,7 @@ export default function App() {
|
|||||||
|
|
||||||
<main className="screen">
|
<main className="screen">
|
||||||
{tab === 'pfd' && (
|
{tab === 'pfd' && (
|
||||||
<Bezel variant="pfd" xp={xp} svt3d={svt3d} onToggleSvt={() => setSvt3d((v) => !v)}
|
<Bezel variant="pfd" xp={xp} knobMode={knobMode} svt3d={svt3d} onToggleSvt={() => setSvt3d((v) => !v)}
|
||||||
inset={inset} onSetInset={setInset} insetMode={insetMode} onInsetMode={setInsetMode}
|
inset={inset} onSetInset={setInset} insetMode={insetMode} onInsetMode={setInsetMode}
|
||||||
nrst={nrst} onToggleNrst={() => setNrst((v) => !v)} onDirect={() => setDto(true)}
|
nrst={nrst} onToggleNrst={() => setNrst((v) => !v)} onDirect={() => setDto(true)}
|
||||||
tmr={tmr} onToggleTmr={() => setTmr((v) => !v)} onProc={() => setProc(true)}>
|
tmr={tmr} onToggleTmr={() => setTmr((v) => !v)} onProc={() => setProc(true)}>
|
||||||
@@ -99,17 +111,33 @@ export default function App() {
|
|||||||
</Bezel>
|
</Bezel>
|
||||||
)}
|
)}
|
||||||
{tab === 'mfd' && (
|
{tab === 'mfd' && (
|
||||||
<Bezel variant="mfd" xp={xp} mapMode={mapMode} onMapMode={setMapMode} onDirect={() => setDto(true)} onProc={() => setProc(true)}>
|
<Bezel variant="mfd" xp={xp} knobMode={knobMode} mapMode={mapMode} onMapMode={setMapMode} onDirect={() => setDto(true)} onProc={() => setProc(true)}>
|
||||||
<MFD values={xp.values} flightPlan={xp.flightPlan} fp={xp.fp} mapMode={mapMode} />
|
<MFD values={xp.values} flightPlan={xp.flightPlan} fp={xp.fp} mapMode={mapMode} />
|
||||||
</Bezel>
|
</Bezel>
|
||||||
)}
|
)}
|
||||||
{tab === 'map' && <MapView values={xp.values} flightPlan={xp.flightPlan} fp={xp.fp} />}
|
{tab === 'map' && <MapView values={xp.values} flightPlan={xp.flightPlan} fp={xp.fp} />}
|
||||||
{tab === 'fms' && <CDU xp={xp} />}
|
{tab === 'fms' && <CDU xp={xp} />}
|
||||||
{tab === 'vfr' && <VFR values={xp.values} />}
|
{tab === 'vfr' && <VFR xp={xp} />}
|
||||||
{tab === 'ap' && <AutopilotPanel xp={xp} />}
|
{tab === 'ap' && <AutopilotPanel xp={xp} />}
|
||||||
</main>
|
</main>
|
||||||
{dto && <DirectTo xp={xp} onClose={() => setDto(false)} />}
|
{dto && <DirectTo xp={xp} onClose={() => setDto(false)} />}
|
||||||
{proc && <Proc xp={xp} onClose={() => setProc(false)} />}
|
{proc && <Proc xp={xp} onClose={() => setProc(false)} />}
|
||||||
|
{settings && (
|
||||||
|
<div className="dlg-backdrop" onClick={() => setSettings(false)}>
|
||||||
|
<div className="dlg" onClick={(e) => e.stopPropagation()} style={{ minWidth: 360 }}>
|
||||||
|
<div className="dlg-head">EINSTELLUNGEN</div>
|
||||||
|
<div style={{ padding: 14 }}>
|
||||||
|
<div className="set-lbl">Knopf-Bedienung</div>
|
||||||
|
<div className="set-opt">
|
||||||
|
<button className={`fbtn ${knobMode === 'arrows' ? 'add' : ''}`} onClick={() => setKnob('arrows')}>Pfeiltasten ˄‹›˅</button>
|
||||||
|
<button className={`fbtn ${knobMode === 'zones' ? 'add' : ''}`} onClick={() => setKnob('zones')}>Klickzonen am Knopf</button>
|
||||||
|
</div>
|
||||||
|
<div className="set-hint">Pfeiltasten sind touch-freundlich. Klickzonen: oben/unten = grob, links/rechts = fein, Mitte = PUSH.</div>
|
||||||
|
</div>
|
||||||
|
<div className="dlg-actions"><button className="fbtn" onClick={() => setSettings(false)}>Schließen</button></div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -34,7 +34,7 @@ const MFD_MENU = {
|
|||||||
// autopilot_state bitfield (best-effort; tweak per aircraft)
|
// 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 };
|
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 }) {
|
export default function Bezel({ variant = 'pfd', xp, svt3d, onToggleSvt, inset, onSetInset, insetMode, onInsetMode, nrst, onToggleNrst, tmr, onToggleTmr, onDirect, onProc, mapMode, onMapMode, knobMode = 'arrows', children }) {
|
||||||
const u = variant === 'mfd' ? 'mfd' : 'pfd'; // command prefix
|
const u = variant === 'mfd' ? 'mfd' : 'pfd'; // command prefix
|
||||||
const fire = (suffix) => xp && xp.command(`${u}_${suffix}`);
|
const fire = (suffix) => xp && xp.command(`${u}_${suffix}`);
|
||||||
const [page, setPage] = useState('root'); // softkey menu page
|
const [page, setPage] = useState('root'); // softkey menu page
|
||||||
@@ -105,12 +105,12 @@ export default function Bezel({ variant = 'pfd', xp, svt3d, onToggleSvt, inset,
|
|||||||
return (
|
return (
|
||||||
<div className="bezel">
|
<div className="bezel">
|
||||||
<div className="bezel-knobs left">
|
<div className="bezel-knobs left">
|
||||||
<Knob label="NAV" sub="VOL · PUSH ID" fire={fire}
|
<Knob label="NAV" sub="VOL · PUSH ID" fire={fire} mode={knobMode}
|
||||||
outer={['nav_outer_up', 'nav_outer_down']} inner={['nav_inner_up', 'nav_inner_down']} push="nav12" />
|
outer={['nav_outer_up', 'nav_outer_down']} inner={['nav_inner_up', 'nav_inner_down']} push="nav12" />
|
||||||
<Knob label="HDG" sub="PUSH HDG SYNC" fire={fire}
|
<Knob label="HDG" sub="PUSH HDG SYNC" fire={fire} mode={knobMode}
|
||||||
outer={['hdg_up', 'hdg_down']} push="hdg_sync" />
|
outer={['hdg_up', 'hdg_down']} push="hdg_sync" />
|
||||||
{variant === 'mfd' && xp && <APController xp={xp} />}
|
{variant === 'mfd' && xp && <APController xp={xp} />}
|
||||||
<Knob label="ALT" sub="" big fire={fire}
|
<Knob label="ALT" sub="" big fire={fire} mode={knobMode}
|
||||||
outer={['alt_outer_up', 'alt_outer_down']} inner={['alt_inner_up', 'alt_inner_down']} />
|
outer={['alt_outer_up', 'alt_outer_down']} inner={['alt_inner_up', 'alt_inner_down']} />
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -133,18 +133,18 @@ export default function Bezel({ variant = 'pfd', xp, svt3d, onToggleSvt, inset,
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="bezel-knobs right">
|
<div className="bezel-knobs right">
|
||||||
<Knob label="COM" sub="VOL · PUSH SQ" fire={fire}
|
<Knob label="COM" sub="VOL · PUSH SQ" fire={fire} mode={knobMode}
|
||||||
outer={['com_outer_up', 'com_outer_down']} inner={['com_inner_up', 'com_inner_down']} push="com12" />
|
outer={['com_outer_up', 'com_outer_down']} inner={['com_inner_up', 'com_inner_down']} push="com12" />
|
||||||
<Knob label="CRS / BARO" sub="PUSH CRS CTR" fire={fire}
|
<Knob label="CRS / BARO" sub="PUSH CRS CTR" fire={fire} mode={knobMode}
|
||||||
outer={['crs_up', 'crs_down']} inner={['baro_up', 'baro_down']} push="crs_sync" />
|
outer={['crs_up', 'crs_down']} inner={['baro_up', 'baro_down']} push="crs_sync" />
|
||||||
<Knob label="RANGE" sub="PUSH PAN" joy fire={fire}
|
<Knob label="RANGE" sub="PUSH PAN" joy fire={fire} mode={knobMode}
|
||||||
outer={['range_up', 'range_down']} push="pan_push" pan />
|
outer={['range_up', 'range_down']} push="pan_push" pan />
|
||||||
<div className="bezel-grid">
|
<div className="bezel-grid">
|
||||||
<BtnG fire={fire} cmd="direct" onClick={onDirect}>D→</BtnG><BtnG fire={fire} cmd="menu">MENU</BtnG>
|
<BtnG fire={fire} mode={knobMode} cmd="direct" onClick={onDirect}>D→</BtnG><BtnG fire={fire} mode={knobMode} cmd="menu">MENU</BtnG>
|
||||||
<BtnG fire={fire} cmd="fpl">FPL</BtnG><BtnG fire={fire} cmd="proc" onClick={onProc}>PROC</BtnG>
|
<BtnG fire={fire} mode={knobMode} cmd="fpl">FPL</BtnG><BtnG fire={fire} mode={knobMode} cmd="proc" onClick={onProc}>PROC</BtnG>
|
||||||
<BtnG fire={fire} cmd="clr">CLR</BtnG><BtnG fire={fire} cmd="ent">ENT</BtnG>
|
<BtnG fire={fire} mode={knobMode} cmd="clr">CLR</BtnG><BtnG fire={fire} mode={knobMode} cmd="ent">ENT</BtnG>
|
||||||
</div>
|
</div>
|
||||||
<Knob label="FMS" sub="PUSH CRSR" big fire={fire}
|
<Knob label="FMS" sub="PUSH CRSR" big fire={fire} mode={knobMode}
|
||||||
outer={['fms_outer_up', 'fms_outer_down']} inner={['fms_inner_up', 'fms_inner_down']} push="cursor" />
|
outer={['fms_outer_up', 'fms_outer_down']} inner={['fms_inner_up', 'fms_inner_down']} push="cursor" />
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -186,30 +186,43 @@ function APController({ xp }) {
|
|||||||
// the mouse wheel; the inner ring via the top/bottom arrows (˄ ˅) and shift+wheel.
|
// 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
|
// Clicking the knob centre fires the push action (PUSH …). The RANGE knob also
|
||||||
// pans with a directional cross.
|
// pans with a directional cross.
|
||||||
function Knob({ label, sub, outer, inner, push, big, joy, pan, fire }) {
|
function Knob({ label, sub, outer, inner, push, big, joy, pan, fire, mode = 'arrows' }) {
|
||||||
const onWheel = (e) => {
|
const onWheel = (e) => {
|
||||||
if (!outer) return;
|
if (!outer) return;
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
const set = (e.shiftKey && inner) ? inner : outer;
|
const set = (e.shiftKey && inner) ? inner : outer;
|
||||||
fire(e.deltaY < 0 ? set[0] : set[1]);
|
fire(e.deltaY < 0 ? set[0] : set[1]);
|
||||||
};
|
};
|
||||||
|
const zoneClick = (e) => {
|
||||||
|
const r = e.currentTarget.getBoundingClientRect();
|
||||||
|
const dx = e.clientX - (r.left + r.width / 2);
|
||||||
|
const dy = e.clientY - (r.top + r.height / 2);
|
||||||
|
const rel = Math.hypot(dx, dy) / (r.width / 2);
|
||||||
|
if (rel < 0.42 && push) { fire(push); return; } // centre → PUSH
|
||||||
|
if (Math.abs(dy) >= Math.abs(dx)) { if (outer) fire(dy < 0 ? outer[0] : outer[1]); }
|
||||||
|
else if (inner) fire(dx > 0 ? inner[0] : inner[1]);
|
||||||
|
else if (outer) fire(dx > 0 ? outer[0] : outer[1]);
|
||||||
|
};
|
||||||
|
const zones = mode === 'zones';
|
||||||
return (
|
return (
|
||||||
<div className={`knob-wrap ${big ? 'big' : ''}`}>
|
<div className={`knob-wrap ${big ? 'big' : ''}`}>
|
||||||
<span className="knob-lbl">{label}</span>
|
<span className="knob-lbl">{label}</span>
|
||||||
<div className="knob-cluster">
|
<div className={`knob-cluster ${zones ? 'zones' : ''}`}>
|
||||||
{inner && <button className="knob-arrow top" onClick={() => fire(inner[0])}>˄</button>}
|
{/* arrows mode (touch-friendly): visible ˄‹›˅ buttons. zones mode: click
|
||||||
{outer && <button className="knob-arrow left" onClick={() => fire(outer[1])}>‹</button>}
|
the knob face itself (top/bottom = outer, left/right = inner). */}
|
||||||
|
{!zones && inner && <button className="knob-arrow top" onClick={() => fire(inner[0])}>˄</button>}
|
||||||
|
{!zones && outer && <button className="knob-arrow left" onClick={() => fire(outer[1])}>‹</button>}
|
||||||
<button
|
<button
|
||||||
className={`knob outer ${joy ? 'joy' : ''}`}
|
className={`knob outer ${joy ? 'joy' : ''}`}
|
||||||
onWheel={onWheel}
|
onWheel={onWheel}
|
||||||
onClick={() => push && fire(push)}
|
onClick={zones ? zoneClick : (() => push && fire(push))}
|
||||||
title={push ? 'PUSH' : ''}
|
title={zones ? `${outer ? 'oben/unten' : ''}${inner ? ' · links/rechts (fein)' : ''}${push ? ' · Mitte: PUSH' : ''}` : (push ? 'PUSH' : '')}
|
||||||
>
|
>
|
||||||
<span className="knob inner" />
|
<span className="knob inner" />
|
||||||
{joy && <div className="joy-cross">+</div>}
|
{joy && <div className="joy-cross">+</div>}
|
||||||
</button>
|
</button>
|
||||||
{outer && <button className="knob-arrow right" onClick={() => fire(outer[0])}>›</button>}
|
{!zones && outer && <button className="knob-arrow right" onClick={() => fire(outer[0])}>›</button>}
|
||||||
{inner && <button className="knob-arrow bottom" onClick={() => fire(inner[1])}>˅</button>}
|
{!zones && inner && <button className="knob-arrow bottom" onClick={() => fire(inner[1])}>˅</button>}
|
||||||
</div>
|
</div>
|
||||||
{pan && (
|
{pan && (
|
||||||
<div className="pan-pad">
|
<div className="pan-pad">
|
||||||
|
|||||||
@@ -0,0 +1,68 @@
|
|||||||
|
import React from 'react';
|
||||||
|
import { num } from '../api/useXplane.js';
|
||||||
|
|
||||||
|
// Bendix/King KAP 140 — the panel-mounted autopilot in the steam-gauge Cessna
|
||||||
|
// 172. A green segment LCD annunciates the active modes + armed altitude, with a
|
||||||
|
// row of buttons (AP HDG NAV APR REV ALT, UP/DN, BARO) and the ALT knob. Buttons
|
||||||
|
// fire X-Plane's own autopilot commands; annunciation comes from autopilot_state.
|
||||||
|
const BITS = { fd: 1 << 0, hdg: 1 << 1, vs: 1 << 4, flc: 1 << 6, nav: 1 << 8, apr: 1 << 9, alt: 1 << 14, bc: 1 << 18 };
|
||||||
|
const on = (s, b) => (num(s) & b) !== 0;
|
||||||
|
|
||||||
|
export default function KAP140({ xp }) {
|
||||||
|
const { values: V, command, setDataref } = xp;
|
||||||
|
const s = num(V.apState), eng = num(V.apEngaged) > 0;
|
||||||
|
const lat = on(s, BITS.apr) ? 'APR' : on(s, BITS.nav) ? 'NAV' : on(s, BITS.bc) ? 'REV' : on(s, BITS.hdg) ? 'HDG' : 'ROL';
|
||||||
|
const vert = on(s, BITS.alt) ? 'ALT' : on(s, BITS.vs) ? 'VS' : '';
|
||||||
|
const selAlt = Math.round(num(V.apAltBug));
|
||||||
|
const vs = Math.round(num(V.apVsBug));
|
||||||
|
|
||||||
|
const Btn = ({ label, cmd }) => (
|
||||||
|
<button className="kap-btn" onClick={() => command(cmd)}>{label}</button>
|
||||||
|
);
|
||||||
|
// A rotary knob: click the upper half to step up, lower half to step down
|
||||||
|
// (also scroll). No +/- buttons.
|
||||||
|
const turn = (e, fn) => {
|
||||||
|
const r = e.currentTarget.getBoundingClientRect();
|
||||||
|
fn(((e.clientY - r.top) < r.height / 2) ? +1 : -1);
|
||||||
|
};
|
||||||
|
const altStep = (d) => setDataref('apAltBug', selAlt + d * 100);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="kap140">
|
||||||
|
<div className="kap-brand">KAP 140</div>
|
||||||
|
<div className="kap-lcd">
|
||||||
|
<div className="kap-l1">
|
||||||
|
<span className={eng ? 'an on' : 'an'}>AP</span>
|
||||||
|
<span className="an on">{lat}</span>
|
||||||
|
<span className="an on">{vert}</span>
|
||||||
|
</div>
|
||||||
|
<div className="kap-l2">
|
||||||
|
<span className="big">{selAlt}</span><span className="u">FT</span>
|
||||||
|
<span className="vs">{vs >= 0 ? '▲' : '▼'} {Math.abs(vs)}<span className="u">FPM</span></span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="kap-keys">
|
||||||
|
<Btn label="AP" cmd="apToggle" />
|
||||||
|
<Btn label="HDG" cmd="hdg" />
|
||||||
|
<Btn label="NAV" cmd="nav" />
|
||||||
|
<Btn label="APR" cmd="apr" />
|
||||||
|
<Btn label="REV" cmd="backCourse" />
|
||||||
|
<Btn label="ALT" cmd="altHold" />
|
||||||
|
<div className="kap-updn">
|
||||||
|
<button className="kap-btn sm" onClick={() => setDataref('apVsBug', vs + 100)}>UP</button>
|
||||||
|
<button className="kap-btn sm" onClick={() => setDataref('apVsBug', vs - 100)}>DN</button>
|
||||||
|
</div>
|
||||||
|
<button className="kap-btn" title="Baro set">BARO</button>
|
||||||
|
</div>
|
||||||
|
<div className="kap-knob">
|
||||||
|
<div className="kap-dial" title="ALT — oben +100 · unten −100"
|
||||||
|
onClick={(e) => turn(e, altStep)}
|
||||||
|
onWheel={(e) => { e.preventDefault(); altStep(e.deltaY < 0 ? 1 : -1); }}>
|
||||||
|
<span className="kdir up">▲</span>
|
||||||
|
<span className="kdir dn">▼</span>
|
||||||
|
</div>
|
||||||
|
<span className="kap-knoblbl">ALT</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -73,8 +73,12 @@ function EisStrip({ V }) {
|
|||||||
const rpm = arr(V.engRpm);
|
const rpm = arr(V.engRpm);
|
||||||
const ffGph = (arr(V.fuelFlow) * 3600) / KG_PER_GAL;
|
const ffGph = (arr(V.fuelFlow) * 3600) / KG_PER_GAL;
|
||||||
const oilPsi = arr(V.oilPress);
|
const oilPsi = arr(V.oilPress);
|
||||||
const oilF = arr(V.oilTemp) * 9 / 5 + 32;
|
// X-Plane's temperature indicator datarefs may already honor the user's unit
|
||||||
const egtF = arr(V.egt) * 9 / 5 + 32;
|
// (°F) despite the "_deg_C" name. Auto-detect: only convert if it still looks
|
||||||
|
// like Celsius, so we don't double-convert (which pegged the gauges red).
|
||||||
|
const oilT = arr(V.oilTemp), egtT = arr(V.egt);
|
||||||
|
const oilF = oilT > 150 ? oilT : oilT * 9 / 5 + 32;
|
||||||
|
const egtF = egtT > 900 ? egtT : egtT * 9 / 5 + 32;
|
||||||
const fuelL = arr(V.fuelQty, 0) / KG_PER_GAL;
|
const fuelL = arr(V.fuelQty, 0) / KG_PER_GAL;
|
||||||
const fuelR = arr(V.fuelQty, 1) / KG_PER_GAL;
|
const fuelR = arr(V.fuelQty, 1) / KG_PER_GAL;
|
||||||
const volts = arr(V.volts, 0, 28);
|
const volts = arr(V.volts, 0, 28);
|
||||||
|
|||||||
+59
-43
@@ -1,4 +1,4 @@
|
|||||||
import React, { useRef, useState, useLayoutEffect, Suspense, lazy } from 'react';
|
import React, { useRef, useState, useEffect, useLayoutEffect, Suspense, lazy } from 'react';
|
||||||
import { num } from '../api/useXplane.js';
|
import { num } from '../api/useXplane.js';
|
||||||
import MapView from './MapView.jsx';
|
import MapView from './MapView.jsx';
|
||||||
import Nearest from './Nearest.jsx';
|
import Nearest from './Nearest.jsx';
|
||||||
@@ -191,15 +191,15 @@ function RadioBar({ V }) {
|
|||||||
<text x="676" y="60" fill="#fff" fontSize="14">BRG</text>
|
<text x="676" y="60" fill="#fff" fontSize="14">BRG</text>
|
||||||
|
|
||||||
{/* COM1 / COM2 (right) */}
|
{/* COM1 / COM2 (right) */}
|
||||||
<text x="720" y="28" fill="#0f0" fontSize="19">{comF(V.com1)}</text>
|
<text x="716" y="28" fill="#0f0" fontSize="19">{comF(V.com1)}</text>
|
||||||
{swap(848, 28)}
|
{swap(844, 28)}
|
||||||
<rect x="876" y="11" width="100" height="22" fill="none" stroke="#0ff" strokeWidth="1.4" />
|
<rect x="862" y="12" width="94" height="22" rx="2" fill="none" stroke="#0ff" strokeWidth="1.4" />
|
||||||
<text x="970" y="28" fill="#0ff" fontSize="19" textAnchor="end">{comF(V.com1Sb)}</text>
|
<text x="950" 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={W - 6} y="26" fill="#9aa" fontSize="12" textAnchor="end">COM1</text>
|
||||||
<text x="720" y="60" fill="#fff" fontSize="19">{comF(V.com2)}</text>
|
<text x="716" y="60" fill="#fff" fontSize="19">{comF(V.com2)}</text>
|
||||||
{swap(848, 60)}
|
{swap(844, 60)}
|
||||||
<text x="970" y="60" fill="#fff" fontSize="19" textAnchor="end">{comF(V.com2Sb)}</text>
|
<text x="950" 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>
|
<text x={W - 6} y="58" fill="#9aa" fontSize="12" textAnchor="end">COM2</text>
|
||||||
</g>
|
</g>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -209,16 +209,39 @@ function Attitude({ V, svt }) {
|
|||||||
const pitch = num(V.pitch), roll = num(V.roll), slip = num(V.slip);
|
const pitch = num(V.pitch), roll = num(V.roll), slip = num(V.slip);
|
||||||
const fdP = num(V.fdPitch), fdR = num(V.fdRoll);
|
const fdP = num(V.fdPitch), fdR = num(V.fdRoll);
|
||||||
const cx = W / 2, cy = 270;
|
const cx = W / 2, cy = 270;
|
||||||
const off = pitch * PITCH_PX;
|
const rollRef = useRef(null), pitchRef = useRef(null), fdRef = useRef(null), bankRef = useRef(null);
|
||||||
|
// Target attitude (updated every render); a rAF loop eases the displayed
|
||||||
|
// transforms toward it at 60 fps — decoupled from X-Plane's ~20 Hz samples,
|
||||||
|
// so the horizon glides instead of stepping.
|
||||||
|
const tgt = useRef({ p: 0, r: 0, fp: 0, fr: 0 });
|
||||||
|
tgt.current = { p: pitch, r: roll, fp: pitch - fdP, fr: roll - fdR };
|
||||||
|
useEffect(() => {
|
||||||
|
let raf; const d = { ...tgt.current };
|
||||||
|
const loop = () => {
|
||||||
|
const t = tgt.current, k = 0.4;
|
||||||
|
d.p += (t.p - d.p) * k; d.r += (t.r - d.r) * k;
|
||||||
|
d.fp += (t.fp - d.fp) * k; d.fr += (t.fr - d.fr) * k;
|
||||||
|
rollRef.current?.setAttribute('transform', `rotate(${-d.r} ${cx} ${cy})`);
|
||||||
|
pitchRef.current?.setAttribute('transform', `translate(0 ${d.p * PITCH_PX})`);
|
||||||
|
bankRef.current?.setAttribute('transform', `rotate(${-d.r} ${cx} ${cy})`);
|
||||||
|
fdRef.current?.setAttribute('transform', `translate(0 ${d.fp * PITCH_PX}) rotate(${d.fr} ${cx} ${cy})`);
|
||||||
|
raf = requestAnimationFrame(loop);
|
||||||
|
};
|
||||||
|
raf = requestAnimationFrame(loop);
|
||||||
|
return () => cancelAnimationFrame(raf);
|
||||||
|
}, []); // eslint-disable-line
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<g>
|
<g>
|
||||||
<defs>
|
<defs>
|
||||||
<clipPath id="att"><rect x={cx - 290} y={cy - 175} width={580} height={385} /></clipPath>
|
{/* full-screen attitude, exactly like the SVT box: blue/brown fills the
|
||||||
|
ENTIRE PFD below the radio bar (behind the tapes, HSI and data strip),
|
||||||
|
same region as the 3D terrain so both look identical full-screen. */}
|
||||||
|
<clipPath id="att"><rect x={SVT_BOX.x} y={SVT_BOX.y} width={SVT_BOX.w} height={SVT_BOX.h} /></clipPath>
|
||||||
</defs>
|
</defs>
|
||||||
<g clipPath="url(#att)">
|
<g clipPath="url(#att)">
|
||||||
<g transform={`rotate(${-roll} ${cx} ${cy})`}>
|
<g ref={rollRef}>
|
||||||
<g transform={`translate(0 ${off})`}>
|
<g ref={pitchRef}>
|
||||||
{/* sky/ground only when SVT is off — otherwise the 3D terrain shows */}
|
{/* 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 - 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} width={1600} height={1100} fill="url(#ground)" />}
|
||||||
@@ -226,17 +249,19 @@ function Attitude({ V, svt }) {
|
|||||||
{pitchLadder(cx, cy)}
|
{pitchLadder(cx, cy)}
|
||||||
</g>
|
</g>
|
||||||
</g>
|
</g>
|
||||||
{/* flight director command bars (magenta) */}
|
{/* flight director command bars — magenta filled chevron (single cue) */}
|
||||||
<g transform={`translate(0 ${(pitch - fdP) * PITCH_PX}) rotate(${roll - fdR} ${cx} ${cy})`}>
|
<g ref={fdRef} fill="#e24de0" stroke="#5a1a58" strokeWidth="1">
|
||||||
<path d={`M${cx - 90} ${cy + 16} L${cx} ${cy - 6} L${cx + 90} ${cy + 16}`}
|
<polygon points={`${cx - 5},${cy + 13} ${cx - 116},${cy + 45} ${cx - 5},${cy + 26}`} />
|
||||||
fill="none" stroke="#e040fb" strokeWidth="6" strokeLinejoin="round" />
|
<polygon points={`${cx + 5},${cy + 13} ${cx + 116},${cy + 45} ${cx + 5},${cy + 26}`} />
|
||||||
</g>
|
</g>
|
||||||
</g>
|
</g>
|
||||||
{rollArc(cx, cy, roll, slip)}
|
{rollArc(cx, cy, slip, bankRef)}
|
||||||
{/* fixed aircraft reference (yellow) */}
|
{/* fixed aircraft reference — yellow chevron (single cue) + side wing markers */}
|
||||||
<g stroke="#ffcc00" strokeWidth="6" fill="#111" strokeLinejoin="round">
|
<g fill="#ffce00" stroke="#2a2200" strokeWidth="1">
|
||||||
<path d={`M${cx - 150} ${cy} h60 l16 20 h-76 z`} />
|
<polygon points={`${cx - 5},${cy} ${cx - 118},${cy + 30} ${cx - 5},${cy + 13}`} />
|
||||||
<path d={`M${cx + 150} ${cy} h-60 l-16 20 h76 z`} />
|
<polygon points={`${cx + 5},${cy} ${cx + 118},${cy + 30} ${cx + 5},${cy + 13}`} />
|
||||||
|
<polygon points={`158,${cy - 6} 192,${cy - 6} 204,${cy} 192,${cy + 6} 158,${cy + 6}`} />
|
||||||
|
<polygon points={`${W - 158},${cy - 6} ${W - 192},${cy - 6} ${W - 204},${cy} ${W - 192},${cy + 6} ${W - 158},${cy + 6}`} />
|
||||||
</g>
|
</g>
|
||||||
{/* flight path marker (green) — track/AOA based; offset approximated */}
|
{/* flight path marker (green) — track/AOA based; offset approximated */}
|
||||||
{(() => {
|
{(() => {
|
||||||
@@ -250,7 +275,6 @@ function Attitude({ V, svt }) {
|
|||||||
</g>
|
</g>
|
||||||
);
|
);
|
||||||
})()}
|
})()}
|
||||||
{!svt && <rect x={cx - 290} y={cy - 175} width={580} height={385} fill="none" stroke="#000" strokeWidth="2" />}
|
|
||||||
</g>
|
</g>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -273,7 +297,7 @@ function pitchLadder(cx, cy) {
|
|||||||
return <g>{m}</g>;
|
return <g>{m}</g>;
|
||||||
}
|
}
|
||||||
|
|
||||||
function rollArc(cx, cy, roll, slip) {
|
function rollArc(cx, cy, slip, bankRef) {
|
||||||
const r = 165;
|
const r = 165;
|
||||||
const ticks = [-60, -45, -30, -20, -10, 0, 10, 20, 30, 45, 60];
|
const ticks = [-60, -45, -30, -20, -10, 0, 10, 20, 30, 45, 60];
|
||||||
return (
|
return (
|
||||||
@@ -286,7 +310,7 @@ function rollArc(cx, cy, roll, slip) {
|
|||||||
x2={cx + r2 * Math.cos(a)} y2={cy + r2 * Math.sin(a)} stroke="#fff" strokeWidth={big ? 3 : 2} />;
|
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" />
|
<path d={`M${cx} ${cy - r - 16} l-11 -16 h22 z`} fill="#fff" />
|
||||||
<g transform={`rotate(${-roll} ${cx} ${cy})`}>
|
<g ref={bankRef}>
|
||||||
<path d={`M${cx} ${cy - r + 2} l-11 18 h22 z`} fill="#ffcc00" />
|
<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" />
|
<rect x={cx - 16 + slip * 7} y={cy - r + 22} width={32} height={9} rx={2} fill="#ffcc00" stroke="#000" />
|
||||||
</g>
|
</g>
|
||||||
@@ -300,13 +324,14 @@ function rollArc(cx, cy, roll, slip) {
|
|||||||
const VSPEEDS = [{ s: 74, l: 'Y' }, { s: 62, l: 'X' }, { s: 68, l: 'G' }];
|
const VSPEEDS = [{ s: 74, l: 'Y' }, { s: 62, l: 'X' }, { s: 68, l: 'G' }];
|
||||||
function AirspeedTape({ V }) {
|
function AirspeedTape({ V }) {
|
||||||
const ias = num(V.airspeed), tas = num(V.tas), spdBug = num(V.apSpdBug);
|
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 x = 60, top = 110, h = 350, cy = top + h / 2, px = 3.6;
|
||||||
const W2 = 84, sx = x + W2 - 7; // colour strip at the right inner edge
|
const W2 = 84, sx = x + W2 - 7; // colour strip at the right inner edge
|
||||||
const ticks = [];
|
const ticks = [];
|
||||||
const lo = Math.floor((ias - 50) / 10) * 10;
|
const lo = Math.floor((ias - 50) / 10) * 10;
|
||||||
for (let s = lo; s <= ias + 50; s += 10) {
|
for (let s = lo; s <= ias + 50; s += 10) {
|
||||||
if (s < 0) continue;
|
if (s < 0) continue;
|
||||||
const y = cy + (ias - s) * px;
|
const y = cy + (ias - s) * px;
|
||||||
|
if (y < top + 2 || y > top + h - 2) continue; // keep ticks inside the tape
|
||||||
ticks.push(<g key={s}><line x1={x + 48} y1={y} x2={x + 60} y2={y} stroke="#fff" strokeWidth="2" />
|
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>);
|
<text x={x + 42} y={y + 7} textAnchor="end" fill="#fff" fontSize="22" fontFamily="monospace">{s}</text></g>);
|
||||||
}
|
}
|
||||||
@@ -316,7 +341,7 @@ function AirspeedTape({ V }) {
|
|||||||
const valid = ias >= 20;
|
const valid = ias >= 20;
|
||||||
return (
|
return (
|
||||||
<g fontFamily="monospace">
|
<g fontFamily="monospace">
|
||||||
<rect x={x} y={top} width={W2} height={h} fill="#0e1626c8" />
|
<rect x={x} y={top} width={W2} height={h} fill="#9aa6b3" fillOpacity="0.34" />
|
||||||
{/* V-speed colour strip (white flap arc, green normal, yellow caution, red Vne) */}
|
{/* V-speed colour strip (white flap arc, green normal, yellow caution, red Vne) */}
|
||||||
{band(33, 85, '#e8e8e8')}
|
{band(33, 85, '#e8e8e8')}
|
||||||
{band(48, 129, '#16c116')}
|
{band(48, 129, '#16c116')}
|
||||||
@@ -329,18 +354,9 @@ function AirspeedTape({ V }) {
|
|||||||
<polygon points={`${x + W2},${cy} ${x + W2 - 18},${cy - 22} ${x - 30},${cy - 22} ${x - 30},${cy + 22} ${x + W2 - 18},${cy + 22}`}
|
<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" />
|
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>
|
<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 */}
|
{/* TAS readout directly below the tape, like the real G1000 */}
|
||||||
{VSPEEDS.map((v, i) => (
|
<text x={x + 4} y={top + h + 22} fill="#fff" fontSize="15">TAS</text>
|
||||||
<g key={v.l}>
|
<text x={x + W2} y={top + h + 22} textAnchor="end" fill="#fff" fontSize="16">{Math.round(tas)}<tspan fontSize="12">KT</tspan></text>
|
||||||
<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>
|
</g>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -348,11 +364,12 @@ function AirspeedTape({ V }) {
|
|||||||
/* ---------------- altitude tape + VSI + baro ---------------- */
|
/* ---------------- altitude tape + VSI + baro ---------------- */
|
||||||
function AltitudeTape({ V }) {
|
function AltitudeTape({ V }) {
|
||||||
const alt = num(V.altitude), vs = num(V.vspeed), altBug = num(V.apAltBug), baro = num(V.baro, 29.92);
|
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 x = W - 70 - 84, W2 = 84, top = 110, h = 350, cy = top + h / 2, px = 0.42;
|
||||||
const ticks = [];
|
const ticks = [];
|
||||||
const lo = Math.floor((alt - 420) / 100) * 100;
|
const lo = Math.floor((alt - 420) / 100) * 100;
|
||||||
for (let a = lo; a <= alt + 420; a += 100) {
|
for (let a = lo; a <= alt + 420; a += 100) {
|
||||||
const y = cy + (alt - a) * px;
|
const y = cy + (alt - a) * px;
|
||||||
|
if (y < top + 2 || y > top + h - 2) continue; // keep ticks inside the tape
|
||||||
ticks.push(<g key={a}><line x1={x + W2 - 18} y1={y} x2={x + W2 - 4} y2={y} stroke="#fff" strokeWidth="2" />
|
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>);
|
<text x={x + W2 - 24} y={y + 7} textAnchor="end" fill="#fff" fontSize="19" fontFamily="monospace">{a}</text></g>);
|
||||||
}
|
}
|
||||||
@@ -372,7 +389,7 @@ function AltitudeTape({ V }) {
|
|||||||
{/* selected altitude (cyan) above the tape */}
|
{/* 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" />
|
<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>
|
<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" />
|
<rect x={x} y={top} width={W2} height={h} fill="#9aa6b3" fillOpacity="0.34" />
|
||||||
{ticks}
|
{ticks}
|
||||||
{/* selected-altitude bug (cyan) on the tape */}
|
{/* selected-altitude bug (cyan) on the tape */}
|
||||||
<path d={`M${x} ${bugY - 7} h7 v14 h-7 z`} fill="none" stroke="#0ff" strokeWidth="2" />
|
<path d={`M${x} ${bugY - 7} h7 v14 h-7 z`} fill="none" stroke="#0ff" strokeWidth="2" />
|
||||||
@@ -574,7 +591,6 @@ function DataStrip({ V }) {
|
|||||||
const lcl = `${String(now.getHours()).padStart(2, '0')}:${String(now.getMinutes()).padStart(2, '0')}:${String(now.getSeconds()).padStart(2, '0')}`;
|
const lcl = `${String(now.getHours()).padStart(2, '0')}:${String(now.getMinutes()).padStart(2, '0')}:${String(now.getSeconds()).padStart(2, '0')}`;
|
||||||
return (
|
return (
|
||||||
<g fontFamily="monospace" fontSize="17">
|
<g fontFamily="monospace" fontSize="17">
|
||||||
<line x1="0" y1="730" x2={W} y2="730" stroke="#3a3a3a" strokeWidth="1.5" />
|
|
||||||
{/* OAT + ISA (left) */}
|
{/* OAT + ISA (left) */}
|
||||||
<rect x="14" y="742" width="118" height="26" fill="#000" stroke="#3a3a3a" />
|
<rect x="14" y="742" width="118" height="26" fill="#000" stroke="#3a3a3a" />
|
||||||
<text x="22" y="761" fill="#fff">OAT {oatF}°F</text>
|
<text x="22" y="761" fill="#fff">OAT {oatF}°F</text>
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
import React from 'react';
|
import React from 'react';
|
||||||
import { num } from '../api/useXplane.js';
|
import { num } from '../api/useXplane.js';
|
||||||
|
import KAP140 from './KAP140.jsx';
|
||||||
|
|
||||||
// Classic analog "six-pack" VFR panel: airspeed, attitude, altimeter, turn
|
// Classic analog "six-pack" VFR panel: airspeed, attitude, altimeter, turn
|
||||||
// coordinator, heading indicator, vertical speed — round steam gauges driven by
|
// coordinator, heading indicator, vertical speed — round steam gauges driven by
|
||||||
@@ -276,10 +277,12 @@ function Clock({ V }) {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
export default function VFR({ values: V }) {
|
export default function VFR({ xp }) {
|
||||||
|
const V = xp.values;
|
||||||
const fuelL = arr0(V.fuelQty, 0) / KG_GAL, fuelR = (Array.isArray(V.fuelQty) ? num(V.fuelQty[1]) : 0) / KG_GAL;
|
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 oilT = arr0(V.oilTemp), egtT = arr0(V.egt);
|
||||||
const egtF = arr0(V.egt) * 9 / 5 + 32, ffGph = (arr0(V.fuelFlow) * 3600) / KG_GAL;
|
const oilF = oilT > 150 ? oilT : oilT * 9 / 5 + 32, oilP = arr0(V.oilPress);
|
||||||
|
const egtF = egtT > 900 ? egtT : egtT * 9 / 5 + 32, ffGph = (arr0(V.fuelFlow) * 3600) / KG_GAL;
|
||||||
const amps = arr0(V.amps);
|
const amps = arr0(V.amps);
|
||||||
return (
|
return (
|
||||||
<div className="vfr-panel">
|
<div className="vfr-panel">
|
||||||
@@ -290,13 +293,14 @@ export default function VFR({ values: V }) {
|
|||||||
<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="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="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' }} />
|
<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 className="vfr-tach"><Tach V={V} /></div>
|
||||||
</div>
|
</div>
|
||||||
<div className="vfr-main">
|
<div className="vfr-main">
|
||||||
<div className="vfr-grid">
|
<div className="vfr-grid">
|
||||||
<ASI V={V} /><AI V={V} /><ALT V={V} />
|
<ASI V={V} /><AI V={V} /><ALT V={V} />
|
||||||
<TC V={V} /><HI V={V} /><VSI V={V} />
|
<TC V={V} /><HI V={V} /><VSI V={V} />
|
||||||
</div>
|
</div>
|
||||||
<div className="vfr-tach"><Tach V={V} /></div>
|
<div className="vfr-ap"><KAP140 xp={xp} /></div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
+45
-7
@@ -100,6 +100,39 @@ body {
|
|||||||
.vfr-gauge svg, .vfr-sg svg { width: 100%; height: auto; filter: drop-shadow(0 4px 12px rgba(0,0,0,.55)); }
|
.vfr-gauge svg, .vfr-sg svg { width: 100%; height: auto; filter: drop-shadow(0 4px 12px rgba(0,0,0,.55)); }
|
||||||
.vfr-name, .vfr-sname { font-family: var(--ui-font); letter-spacing: 1.2px; color: #c9d0d7; font-weight: 600; }
|
.vfr-name, .vfr-sname { font-family: var(--ui-font); letter-spacing: 1.2px; color: #c9d0d7; font-weight: 600; }
|
||||||
.vfr-name { font-size: 11px; } .vfr-sname { font-size: 9px; }
|
.vfr-name { font-size: 11px; } .vfr-sname { font-size: 9px; }
|
||||||
|
.vfr-ap { display: flex; justify-content: center; margin-top: clamp(8px, 1.5vw, 18px); }
|
||||||
|
/* KAP 140 autopilot (steam-gauge C172) */
|
||||||
|
.kap140 { display: flex; align-items: center; gap: 12px; background: linear-gradient(#2a2c30, #161719);
|
||||||
|
border: 1px solid #0a0a0a; border-top: 1px solid #4a4d52; border-radius: 10px; padding: 11px 14px; font-family: var(--ui-font);
|
||||||
|
box-shadow: 0 8px 24px rgba(0,0,0,.6), inset 0 1px 0 rgba(255,255,255,.06); }
|
||||||
|
.kap-brand { color: #8893a0; font-size: 9px; font-weight: 700; letter-spacing: 1px; writing-mode: vertical-rl; transform: rotate(180deg); }
|
||||||
|
.kap-lcd { background: #06160b; border: 1px solid #0a4d24; border-radius: 4px; padding: 7px 11px; min-width: 168px;
|
||||||
|
box-shadow: inset 0 0 16px rgba(0,90,35,.5); font-family: 'Saira Semi Condensed', monospace; }
|
||||||
|
.kap-l1 { display: flex; gap: 12px; align-items: center; }
|
||||||
|
.kap-l1 .an { color: #0c3a1e; font-weight: 700; font-size: 14px; letter-spacing: 1px; }
|
||||||
|
.kap-l1 .an.on { color: #3bff6e; text-shadow: 0 0 8px rgba(59,255,110,.5); }
|
||||||
|
.kap-l2 { display: flex; gap: 14px; align-items: baseline; margin-top: 3px; color: #3bff6e; }
|
||||||
|
.kap-l2 .big { font-size: 22px; font-weight: 700; }
|
||||||
|
.kap-l2 .u { font-size: 10px; color: #1f9d52; margin-left: 2px; }
|
||||||
|
.kap-l2 .vs { font-size: 14px; }
|
||||||
|
.kap-keys { display: flex; gap: 7px; align-items: stretch; }
|
||||||
|
/* physical, G1000-style buttons */
|
||||||
|
.kap-btn { background: linear-gradient(#3b3e44, #23262b); color: #eef2f6; border: 1px solid #08090b; border-top: 1px solid #5c6168;
|
||||||
|
border-radius: 7px; padding: 13px 11px; font-family: var(--ui-font); font-size: 12px; font-weight: 700; letter-spacing: .3px; cursor: pointer; min-width: 44px;
|
||||||
|
box-shadow: 0 2px 4px rgba(0,0,0,.55), inset 0 1px 0 rgba(255,255,255,.10); }
|
||||||
|
.kap-btn:hover { background: linear-gradient(#454951, #2a2d33); }
|
||||||
|
.kap-btn:active { transform: translateY(1px); background: linear-gradient(#1a8f44, #136b32); color: #fff; box-shadow: inset 0 2px 5px rgba(0,0,0,.6); }
|
||||||
|
.kap-btn.sm { padding: 6px 9px; font-size: 10px; min-width: 0; }
|
||||||
|
.kap-updn { display: flex; flex-direction: column; gap: 4px; }
|
||||||
|
/* clickable rotary: top half = up, bottom half = down */
|
||||||
|
.kap-knob { display: flex; flex-direction: column; align-items: center; gap: 3px; }
|
||||||
|
.kap-dial { position: relative; width: 50px; height: 50px; border-radius: 50%; cursor: pointer;
|
||||||
|
background: radial-gradient(circle at 38% 30%, #4e535b, #14161a 72%); border: 1px solid #08090b;
|
||||||
|
box-shadow: 0 2px 6px rgba(0,0,0,.6), inset 0 1px 0 rgba(255,255,255,.14);
|
||||||
|
display: flex; flex-direction: column; align-items: center; justify-content: space-between; padding: 3px 0; }
|
||||||
|
.kap-dial .kdir { color: #aeb6bf; font-size: 11px; line-height: 1; user-select: none; }
|
||||||
|
.kap-dial:hover .kdir { color: #fff; }
|
||||||
|
.kap-knoblbl { color: #8893a0; font-size: 9px; font-weight: 700; letter-spacing: 1px; }
|
||||||
.vfr-clock { background: #0c0d0f; border: 1px solid #2a2f36; border-radius: 6px; padding: 8px 10px; display: flex; flex-direction: column; gap: 4px; }
|
.vfr-clock { background: #0c0d0f; border: 1px solid #2a2f36; border-radius: 6px; padding: 8px 10px; display: flex; flex-direction: column; gap: 4px; }
|
||||||
.vc-row { display: flex; align-items: baseline; justify-content: space-between; gap: 10px; }
|
.vc-row { display: flex; align-items: baseline; justify-content: space-between; gap: 10px; }
|
||||||
.vc-row b { font-family: 'Saira Condensed', monospace; color: #46e0c0; font-size: 18px; }
|
.vc-row b { font-family: 'Saira Condensed', monospace; color: #46e0c0; font-size: 18px; }
|
||||||
@@ -233,16 +266,15 @@ body {
|
|||||||
.bezel-core { flex: 1; display: flex; flex-direction: column; min-width: 0; }
|
.bezel-core { flex: 1; display: flex; flex-direction: column; min-width: 0; }
|
||||||
.bezel-title { text-align: center; color: #c9ced3; font-size: 14px; font-weight: 700; letter-spacing: 3px; padding: 2px 0 6px; }
|
.bezel-title { text-align: center; color: #c9ced3; font-size: 14px; font-weight: 700; letter-spacing: 3px; padding: 2px 0 6px; }
|
||||||
.bezel-screen {
|
.bezel-screen {
|
||||||
flex: 1; background: #000; border-radius: 6px; overflow: hidden; position: relative;
|
flex: 1; background: #000; overflow: hidden; position: relative;
|
||||||
border: 2px solid #0a0a0a; box-shadow: inset 0 0 18px #000, inset 0 0 2px #1a3a5a;
|
|
||||||
display: flex; min-height: 0;
|
display: flex; min-height: 0;
|
||||||
}
|
}
|
||||||
.bezel-screen > * { width: 100%; height: 100%; }
|
.bezel-screen > * { width: 100%; height: 100%; }
|
||||||
.softkeys { display: grid; grid-template-columns: repeat(12, 1fr); gap: 6px; padding: 8px 2px 2px; }
|
.softkeys { display: grid; grid-template-columns: repeat(12, 1fr); gap: 4px; padding: 4px 2px 1px; }
|
||||||
.softkey {
|
.softkey {
|
||||||
height: 30px; display: flex; align-items: center; justify-content: center;
|
height: 20px; display: flex; align-items: center; justify-content: center;
|
||||||
background: linear-gradient(#202224, #131416); border: 1px solid #000; border-top: 1px solid #45474b;
|
background: linear-gradient(#202224, #131416); border: 1px solid #000; border-top: 1px solid #45474b;
|
||||||
border-radius: 4px; color: #cfd6dc; font-size: 12px; font-weight: 600; letter-spacing: .3px;
|
border-radius: 3px; color: #cfd6dc; font-size: 10.5px; font-weight: 600; letter-spacing: .2px;
|
||||||
box-shadow: 0 1px 2px #000; cursor: pointer; font-family: inherit;
|
box-shadow: 0 1px 2px #000; cursor: pointer; font-family: inherit;
|
||||||
}
|
}
|
||||||
.softkey:not(.empty):hover { background: linear-gradient(#2a2c2f, #1a1b1e); border-top-color: #5a5d61; }
|
.softkey:not(.empty):hover { background: linear-gradient(#2a2c2f, #1a1b1e); border-top-color: #5a5d61; }
|
||||||
@@ -258,10 +290,10 @@ body {
|
|||||||
.knob-sub { color: #8b9197; font-size: 8.5px; font-weight: 600; letter-spacing: .3px; text-align: center; }
|
.knob-sub { color: #8b9197; font-size: 8.5px; font-weight: 600; letter-spacing: .3px; text-align: center; }
|
||||||
.knob-extra { position: absolute; right: -10px; top: 6px; width: 20px; height: 16px; background: #1a1b1e; border: 1px solid #000; border-radius: 3px; color: #cfd6dc; font-size: 11px; text-align: center; line-height: 16px; }
|
.knob-extra { position: absolute; right: -10px; top: 6px; width: 20px; height: 16px; background: #1a1b1e; border: 1px solid #000; border-radius: 3px; color: #cfd6dc; font-size: 11px; text-align: center; line-height: 16px; }
|
||||||
.knob.outer {
|
.knob.outer {
|
||||||
width: 50px; height: 50px; border-radius: 50%; display: flex; align-items: center; justify-content: center; position: relative;
|
width: 60px; height: 60px; border-radius: 50%; display: flex; align-items: center; justify-content: center; position: relative;
|
||||||
background: radial-gradient(circle at 35% 30%, #55585d, #2a2c2f 70%); box-shadow: 0 2px 5px #000, inset 0 1px 0 #6a6d72;
|
background: radial-gradient(circle at 35% 30%, #55585d, #2a2c2f 70%); box-shadow: 0 2px 5px #000, inset 0 1px 0 #6a6d72;
|
||||||
}
|
}
|
||||||
.knob-wrap.big .knob.outer { width: 58px; height: 58px; }
|
.knob-wrap.big .knob.outer { width: 68px; height: 68px; }
|
||||||
.knob.inner { width: 26px; height: 26px; border-radius: 50%; background: radial-gradient(circle at 35% 30%, #44474b, #1c1e20); box-shadow: inset 0 1px 0 #5a5d61; }
|
.knob.inner { width: 26px; height: 26px; border-radius: 50%; background: radial-gradient(circle at 35% 30%, #44474b, #1c1e20); box-shadow: inset 0 1px 0 #5a5d61; }
|
||||||
.knob.joy .joy-cross { position: absolute; color: #6a6d72; font-size: 22px; font-weight: 700; pointer-events: none; }
|
.knob.joy .joy-cross { position: absolute; color: #6a6d72; font-size: 22px; font-weight: 700; pointer-events: none; }
|
||||||
.knob.outer { cursor: pointer; border: none; padding: 0; }
|
.knob.outer { cursor: pointer; border: none; padding: 0; }
|
||||||
@@ -278,6 +310,12 @@ body {
|
|||||||
.knob-arrow:active { background: #000; }
|
.knob-arrow:active { background: #000; }
|
||||||
.knob-arrow.left { left: -2px; } .knob-arrow.right { right: -2px; }
|
.knob-arrow.left { left: -2px; } .knob-arrow.right { right: -2px; }
|
||||||
.knob-arrow.top { top: -2px; } .knob-arrow.bottom { bottom: -2px; }
|
.knob-arrow.top { top: -2px; } .knob-arrow.bottom { bottom: -2px; }
|
||||||
|
.knob-cluster.zones { padding: 5px; }
|
||||||
|
/* settings panel */
|
||||||
|
.set-lbl { color: var(--c-mut); font-size: 12px; font-weight: 700; letter-spacing: .5px; margin-bottom: 8px; font-family: var(--ui-font); }
|
||||||
|
.set-opt { display: flex; gap: 8px; }
|
||||||
|
.set-opt .fbtn { flex: 1; }
|
||||||
|
.set-hint { color: var(--c-mut); font-size: 11px; margin-top: 10px; line-height: 1.45; font-family: var(--ui-font); }
|
||||||
|
|
||||||
.pan-pad { display: grid; grid-template-columns: repeat(2, 14px); gap: 2px; margin-top: 3px; }
|
.pan-pad { display: grid; grid-template-columns: repeat(2, 14px); gap: 2px; margin-top: 3px; }
|
||||||
.pan-pad button {
|
.pan-pad button {
|
||||||
|
|||||||
Reference in New Issue
Block a user