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
+32 -19
View File
@@ -34,7 +34,7 @@ const MFD_MENU = {
// autopilot_state bitfield (best-effort; tweak per aircraft)
const AP_BITS = { fd: 1 << 0, hdg: 1 << 1, vs: 1 << 4, flc: 1 << 6, nav: 1 << 8, apr: 1 << 9, vnav: 1 << 11, altHold: 1 << 14, bc: 1 << 18 };
export default function Bezel({ variant = 'pfd', xp, svt3d, onToggleSvt, inset, onSetInset, insetMode, onInsetMode, nrst, onToggleNrst, tmr, onToggleTmr, 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 fire = (suffix) => xp && xp.command(`${u}_${suffix}`);
const [page, setPage] = useState('root'); // softkey menu page
@@ -105,12 +105,12 @@ export default function Bezel({ variant = 'pfd', xp, svt3d, onToggleSvt, inset,
return (
<div className="bezel">
<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" />
<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" />
{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']} />
</div>
@@ -133,18 +133,18 @@ export default function Bezel({ variant = 'pfd', xp, svt3d, onToggleSvt, inset,
</div>
<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" />
<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" />
<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 />
<div className="bezel-grid">
<BtnG fire={fire} cmd="direct" onClick={onDirect}>D</BtnG><BtnG fire={fire} cmd="menu">MENU</BtnG>
<BtnG fire={fire} cmd="fpl">FPL</BtnG><BtnG fire={fire} cmd="proc" onClick={onProc}>PROC</BtnG>
<BtnG fire={fire} cmd="clr">CLR</BtnG><BtnG fire={fire} cmd="ent">ENT</BtnG>
<BtnG fire={fire} mode={knobMode} cmd="direct" onClick={onDirect}>D</BtnG><BtnG fire={fire} mode={knobMode} cmd="menu">MENU</BtnG>
<BtnG fire={fire} mode={knobMode} cmd="fpl">FPL</BtnG><BtnG fire={fire} mode={knobMode} cmd="proc" onClick={onProc}>PROC</BtnG>
<BtnG fire={fire} mode={knobMode} cmd="clr">CLR</BtnG><BtnG fire={fire} mode={knobMode} cmd="ent">ENT</BtnG>
</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" />
</div>
</div>
@@ -186,30 +186,43 @@ function APController({ xp }) {
// the mouse wheel; the inner ring via the top/bottom arrows (˄ ˅) and shift+wheel.
// Clicking the knob centre fires the push action (PUSH …). The RANGE knob also
// pans with a directional cross.
function Knob({ label, sub, outer, inner, push, big, joy, pan, fire }) {
function Knob({ label, sub, outer, inner, push, big, joy, pan, fire, mode = 'arrows' }) {
const onWheel = (e) => {
if (!outer) return;
e.preventDefault();
const set = (e.shiftKey && inner) ? inner : outer;
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 (
<div className={`knob-wrap ${big ? 'big' : ''}`}>
<span className="knob-lbl">{label}</span>
<div className="knob-cluster">
{inner && <button className="knob-arrow top" onClick={() => fire(inner[0])}>˄</button>}
{outer && <button className="knob-arrow left" onClick={() => fire(outer[1])}></button>}
<div className={`knob-cluster ${zones ? 'zones' : ''}`}>
{/* arrows mode (touch-friendly): visible ˄‹›˅ buttons. zones mode: click
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
className={`knob outer ${joy ? 'joy' : ''}`}
onWheel={onWheel}
onClick={() => push && fire(push)}
title={push ? 'PUSH' : ''}
onClick={zones ? zoneClick : (() => push && fire(push))}
title={zones ? `${outer ? 'oben/unten' : ''}${inner ? ' · links/rechts (fein)' : ''}${push ? ' · Mitte: PUSH' : ''}` : (push ? 'PUSH' : '')}
>
<span className="knob inner" />
{joy && <div className="joy-cross"></div>}
</button>
{outer && <button className="knob-arrow right" onClick={() => fire(outer[0])}></button>}
{inner && <button className="knob-arrow bottom" onClick={() => fire(inner[1])}>˅</button>}
{!zones && outer && <button className="knob-arrow right" onClick={() => fire(outer[0])}></button>}
{!zones && inner && <button className="knob-arrow bottom" onClick={() => fire(inner[1])}>˅</button>}
</div>
{pan && (
<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 ffGph = (arr(V.fuelFlow) * 3600) / KG_PER_GAL;
const oilPsi = arr(V.oilPress);
const oilF = arr(V.oilTemp) * 9 / 5 + 32;
const egtF = arr(V.egt) * 9 / 5 + 32;
// X-Plane's temperature indicator datarefs may already honor the user's unit
// (°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 fuelR = arr(V.fuelQty, 1) / KG_PER_GAL;
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 MapView from './MapView.jsx';
import Nearest from './Nearest.jsx';
@@ -191,15 +191,15 @@ function RadioBar({ V }) {
<text x="676" y="60" fill="#fff" fontSize="14">BRG</text>
{/* COM1 / COM2 (right) */}
<text x="720" y="28" fill="#0f0" fontSize="19">{comF(V.com1)}</text>
{swap(848, 28)}
<rect x="876" y="11" width="100" height="22" fill="none" stroke="#0ff" strokeWidth="1.4" />
<text x="970" y="28" fill="#0ff" fontSize="19" textAnchor="end">{comF(V.com1Sb)}</text>
<text x={W - 4} y="28" fill="#fff" fontSize="13" textAnchor="end">COM1</text>
<text x="720" y="60" fill="#fff" fontSize="19">{comF(V.com2)}</text>
{swap(848, 60)}
<text x="970" y="60" fill="#fff" fontSize="19" textAnchor="end">{comF(V.com2Sb)}</text>
<text x={W - 4} y="60" fill="#fff" fontSize="13" textAnchor="end">COM2</text>
<text x="716" y="28" fill="#0f0" fontSize="19">{comF(V.com1)}</text>
{swap(844, 28)}
<rect x="862" y="12" width="94" height="22" rx="2" fill="none" stroke="#0ff" strokeWidth="1.4" />
<text x="950" y="28" fill="#0ff" fontSize="19" textAnchor="end">{comF(V.com1Sb)}</text>
<text x={W - 6} y="26" fill="#9aa" fontSize="12" textAnchor="end">COM1</text>
<text x="716" y="60" fill="#fff" fontSize="19">{comF(V.com2)}</text>
{swap(844, 60)}
<text x="950" y="60" fill="#fff" fontSize="19" textAnchor="end">{comF(V.com2Sb)}</text>
<text x={W - 6} y="58" fill="#9aa" fontSize="12" textAnchor="end">COM2</text>
</g>
);
}
@@ -209,16 +209,39 @@ function Attitude({ V, svt }) {
const pitch = num(V.pitch), roll = num(V.roll), slip = num(V.slip);
const fdP = num(V.fdPitch), fdR = num(V.fdRoll);
const cx = W / 2, cy = 270;
const off = pitch * PITCH_PX;
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 (
<g>
<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>
<g clipPath="url(#att)">
<g transform={`rotate(${-roll} ${cx} ${cy})`}>
<g transform={`translate(0 ${off})`}>
<g ref={rollRef}>
<g ref={pitchRef}>
{/* sky/ground only when SVT is off — otherwise the 3D terrain shows */}
{!svt && <rect x={cx - 800} y={cy - 1100} width={1600} height={1100} fill="url(#sky)" />}
{!svt && <rect x={cx - 800} y={cy} width={1600} height={1100} fill="url(#ground)" />}
@@ -226,17 +249,19 @@ function Attitude({ V, svt }) {
{pitchLadder(cx, cy)}
</g>
</g>
{/* flight director command bars (magenta) */}
<g transform={`translate(0 ${(pitch - fdP) * PITCH_PX}) rotate(${roll - fdR} ${cx} ${cy})`}>
<path d={`M${cx - 90} ${cy + 16} L${cx} ${cy - 6} L${cx + 90} ${cy + 16}`}
fill="none" stroke="#e040fb" strokeWidth="6" strokeLinejoin="round" />
{/* flight director command bars magenta filled chevron (single cue) */}
<g ref={fdRef} fill="#e24de0" stroke="#5a1a58" strokeWidth="1">
<polygon points={`${cx - 5},${cy + 13} ${cx - 116},${cy + 45} ${cx - 5},${cy + 26}`} />
<polygon points={`${cx + 5},${cy + 13} ${cx + 116},${cy + 45} ${cx + 5},${cy + 26}`} />
</g>
</g>
{rollArc(cx, cy, roll, slip)}
{/* fixed aircraft reference (yellow) */}
<g stroke="#ffcc00" strokeWidth="6" fill="#111" strokeLinejoin="round">
<path d={`M${cx - 150} ${cy} h60 l16 20 h-76 z`} />
<path d={`M${cx + 150} ${cy} h-60 l-16 20 h76 z`} />
{rollArc(cx, cy, slip, bankRef)}
{/* fixed aircraft reference yellow chevron (single cue) + side wing markers */}
<g fill="#ffce00" stroke="#2a2200" strokeWidth="1">
<polygon points={`${cx - 5},${cy} ${cx - 118},${cy + 30} ${cx - 5},${cy + 13}`} />
<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>
{/* flight path marker (green) — track/AOA based; offset approximated */}
{(() => {
@@ -250,7 +275,6 @@ function Attitude({ V, svt }) {
</g>
);
})()}
{!svt && <rect x={cx - 290} y={cy - 175} width={580} height={385} fill="none" stroke="#000" strokeWidth="2" />}
</g>
);
}
@@ -273,7 +297,7 @@ function pitchLadder(cx, cy) {
return <g>{m}</g>;
}
function rollArc(cx, cy, roll, slip) {
function rollArc(cx, cy, slip, bankRef) {
const r = 165;
const ticks = [-60, -45, -30, -20, -10, 0, 10, 20, 30, 45, 60];
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} />;
})}
<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" />
<rect x={cx - 16 + slip * 7} y={cy - r + 22} width={32} height={9} rx={2} fill="#ffcc00" stroke="#000" />
</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' }];
function AirspeedTape({ V }) {
const ias = num(V.airspeed), tas = num(V.tas), spdBug = num(V.apSpdBug);
const x = 60, top = 95, h = 350, cy = top + h / 2, px = 3.6;
const 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 ticks = [];
const lo = Math.floor((ias - 50) / 10) * 10;
for (let s = lo; s <= ias + 50; s += 10) {
if (s < 0) continue;
const y = cy + (ias - s) * px;
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" />
<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;
return (
<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) */}
{band(33, 85, '#e8e8e8')}
{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}`}
fill="#000" stroke="#fff" strokeWidth="2" />
<text x={x + W2 - 22} y={cy + 9} textAnchor="end" fill="#fff" fontSize="30" fontWeight="bold">{valid ? Math.round(ias) : '- - -'}</text>
{/* V-speed reference list below the tape */}
{VSPEEDS.map((v, i) => (
<g key={v.l}>
<text x={x + 40} y={top + h + 24 + i * 24} textAnchor="end" fill="#0ff" fontSize="18">{v.s}</text>
<rect x={x + 46} y={top + h + 10 + i * 24} width="18" height="18" fill="#0ff" />
<text x={x + 55} y={top + h + 24 + i * 24} textAnchor="middle" fill="#000" fontSize="15" fontWeight="bold">{v.l}</text>
</g>
))}
{/* TAS box at the very bottom */}
<rect x={x} y={top + h + 84} width={W2} height={26} fill="#000" stroke="#3a3a3a" />
<text x={x + 6} y={top + h + 103} fill="#0ff" fontSize="14">TAS</text>
<text x={x + W2 - 6} y={top + h + 103} textAnchor="end" fill="#fff" fontSize="16">{Math.round(tas)}</text>
{/* TAS readout directly below the tape, like the real G1000 */}
<text x={x + 4} y={top + h + 22} fill="#fff" fontSize="15">TAS</text>
<text x={x + W2} y={top + h + 22} textAnchor="end" fill="#fff" fontSize="16">{Math.round(tas)}<tspan fontSize="12">KT</tspan></text>
</g>
);
}
@@ -348,11 +364,12 @@ function AirspeedTape({ V }) {
/* ---------------- altitude tape + VSI + baro ---------------- */
function AltitudeTape({ V }) {
const alt = num(V.altitude), vs = num(V.vspeed), altBug = num(V.apAltBug), baro = num(V.baro, 29.92);
const x = W - 70 - 84, W2 = 84, top = 95, h = 350, cy = top + h / 2, px = 0.42;
const x = W - 70 - 84, W2 = 84, top = 110, h = 350, cy = top + h / 2, px = 0.42;
const ticks = [];
const lo = Math.floor((alt - 420) / 100) * 100;
for (let a = lo; a <= alt + 420; a += 100) {
const y = cy + (alt - a) * px;
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" />
<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 */}
<rect x={x - 6} y={top - 32} width={W2 + 6} height={26} fill="#000" stroke="#0ff" strokeWidth="1.4" />
<text x={x + W2 - 6} y={top - 13} textAnchor="end" fill="#0ff" fontSize="19">{selStr}</text>
<rect x={x} y={top} width={W2} height={h} fill="#0e1626c8" />
<rect x={x} y={top} width={W2} height={h} fill="#9aa6b3" fillOpacity="0.34" />
{ticks}
{/* selected-altitude bug (cyan) on the tape */}
<path d={`M${x} ${bugY - 7} h7 v14 h-7 z`} fill="none" stroke="#0ff" strokeWidth="2" />
@@ -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')}`;
return (
<g fontFamily="monospace" fontSize="17">
<line x1="0" y1="730" x2={W} y2="730" stroke="#3a3a3a" strokeWidth="1.5" />
{/* OAT + ISA (left) */}
<rect x="14" y="742" width="118" height="26" fill="#000" stroke="#3a3a3a" />
<text x="22" y="761" fill="#fff">OAT {oatF}°F</text>
+8 -4
View File
@@ -1,5 +1,6 @@
import React from 'react';
import { num } from '../api/useXplane.js';
import KAP140 from './KAP140.jsx';
// Classic analog "six-pack" VFR panel: airspeed, attitude, altimeter, turn
// 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 oilF = arr0(V.oilTemp) * 9 / 5 + 32, oilP = arr0(V.oilPress);
const egtF = arr0(V.egt) * 9 / 5 + 32, ffGph = (arr0(V.fuelFlow) * 3600) / KG_GAL;
const oilT = arr0(V.oilTemp), egtT = arr0(V.egt);
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);
return (
<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="EGT · FF" l={{ value: egtF, min: 800, max: 1650, tag: 'EGT' }} r={{ value: ffGph, min: 0, max: 20, green: [0, 17], tag: 'GPH' }} />
<Dual title="VAC · AMP" l={{ value: 5, min: 0, max: 10, green: [4.5, 5.5], tag: 'SUC' }} r={{ value: amps, min: -60, max: 60, green: [0, 60], tag: 'AMP' }} />
<div className="vfr-tach"><Tach V={V} /></div>
</div>
<div className="vfr-main">
<div className="vfr-grid">
<ASI V={V} /><AI V={V} /><ALT V={V} />
<TC V={V} /><HI V={V} /><VSI V={V} />
</div>
<div className="vfr-tach"><Tach V={V} /></div>
<div className="vfr-ap"><KAP140 xp={xp} /></div>
</div>
</div>
</div>