diff --git a/web/src/components/KAP140.jsx b/web/src/components/KAP140.jsx
new file mode 100644
index 0000000..e5b4f7b
--- /dev/null
+++ b/web/src/components/KAP140.jsx
@@ -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 }) => (
+
+ );
+ // 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 (
+
+
KAP 140
+
+
+ AP
+ {lat}
+ {vert}
+
+
+ {selAlt}FT
+ {vs >= 0 ? '▲' : '▼'} {Math.abs(vs)}FPM
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
turn(e, altStep)}
+ onWheel={(e) => { e.preventDefault(); altStep(e.deltaY < 0 ? 1 : -1); }}>
+ ▲
+ ▼
+
+
ALT
+
+
+ );
+}
diff --git a/web/src/components/MFD.jsx b/web/src/components/MFD.jsx
index 3f44aaf..fdea8b2 100644
--- a/web/src/components/MFD.jsx
+++ b/web/src/components/MFD.jsx
@@ -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);
diff --git a/web/src/components/PFD.jsx b/web/src/components/PFD.jsx
index 72c4354..3cffc21 100644
--- a/web/src/components/PFD.jsx
+++ b/web/src/components/PFD.jsx
@@ -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 }) {
BRG
{/* COM1 / COM2 (right) */}
-
{comF(V.com1)}
- {swap(848, 28)}
-
-
{comF(V.com1Sb)}
-
COM1
-
{comF(V.com2)}
- {swap(848, 60)}
-
{comF(V.com2Sb)}
-
COM2
+
{comF(V.com1)}
+ {swap(844, 28)}
+
+
{comF(V.com1Sb)}
+
COM1
+
{comF(V.com2)}
+ {swap(844, 60)}
+
{comF(V.com2Sb)}
+
COM2
);
}
@@ -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 (
-
+ {/* 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. */}
+
-
-
+
+
{/* sky/ground only when SVT is off — otherwise the 3D terrain shows */}
{!svt && }
{!svt && }
@@ -226,17 +249,19 @@ function Attitude({ V, svt }) {
{pitchLadder(cx, cy)}
- {/* flight director command bars (magenta) */}
-
-
+ {/* flight director command bars — magenta filled chevron (single cue) */}
+
+
+
- {rollArc(cx, cy, roll, slip)}
- {/* fixed aircraft reference (yellow) */}
-
-
-
+ {rollArc(cx, cy, slip, bankRef)}
+ {/* fixed aircraft reference — yellow chevron (single cue) + side wing markers */}
+
+
+
+
+
{/* flight path marker (green) — track/AOA based; offset approximated */}
{(() => {
@@ -250,7 +275,6 @@ function Attitude({ V, svt }) {
);
})()}
- {!svt && }
);
}
@@ -273,7 +297,7 @@ function pitchLadder(cx, cy) {
return {m};
}
-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} />;
})}
-
+
@@ -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(
{s});
}
@@ -316,7 +341,7 @@ function AirspeedTape({ V }) {
const valid = ias >= 20;
return (
-
+
{/* 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 }) {
{valid ? Math.round(ias) : '- - -'}
- {/* V-speed reference list below the tape */}
- {VSPEEDS.map((v, i) => (
-
- {v.s}
-
- {v.l}
-
- ))}
- {/* TAS box at the very bottom */}
-
- TAS
- {Math.round(tas)}
+ {/* TAS readout directly below the tape, like the real G1000 */}
+ TAS
+ {Math.round(tas)}KT
);
}
@@ -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(
{a});
}
@@ -372,7 +389,7 @@ function AltitudeTape({ V }) {
{/* selected altitude (cyan) above the tape */}
{selStr}
-
+
{ticks}
{/* selected-altitude bug (cyan) on the tape */}
@@ -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 (
-
{/* OAT + ISA (left) */}
OAT {oatF}°F
diff --git a/web/src/components/VFR.jsx b/web/src/components/VFR.jsx
index 36bc079..8187aef 100644
--- a/web/src/components/VFR.jsx
+++ b/web/src/components/VFR.jsx
@@ -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 (
@@ -290,13 +293,14 @@ export default function VFR({ values: V }) {
+