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:
2026-06-01 17:20:16 +02:00
parent ebc33a78b7
commit 354ea5d44b
10 changed files with 253 additions and 82 deletions
+1 -1
View File
@@ -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 -1
View File
@@ -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 -1
View File
@@ -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
View File
@@ -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>
); );
} }
+32 -19
View File
@@ -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">
+68
View File
@@ -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>
);
}
+6 -2
View File
@@ -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
View File
@@ -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>
+8 -4
View File
@@ -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
View File
@@ -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 {