Files
xplane-cockpit/web/src/components/VFR.jsx
T
karim ebc33a78b7 Initial commit: X-Plane G1000 web cockpit + bridge + Tauri desktop app
- server/: Node bridge (datarefs/commands, navdata, CIFP procedures, flight plan)
- web/: React cockpit (PFD/MFD/Map, VFR six-pack, AFCS, FMS CDU), PWA, collapsible sidebar
- desktop/: Tauri 2 launcher (Bun sidecar, system tray, updater) + Linux build via Docker
- scripts/: prep-desktop, build-linux, Gitea release + latest.json

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-01 15:07:03 +02:00

305 lines
14 KiB
React

import React from 'react';
import { num } from '../api/useXplane.js';
// Classic analog "six-pack" VFR panel: airspeed, attitude, altimeter, turn
// coordinator, heading indicator, vertical speed — round steam gauges driven by
// the same X-Plane datarefs. For steam/VFR aircraft (and just because it looks
// great). Each gauge is a self-contained SVG on a dark instrument panel.
const arr0 = (v, d = 0) => (Array.isArray(v) ? num(v[0], d) : num(v, d));
const clamp = (v, lo, hi) => Math.max(lo, Math.min(hi, v));
// point on a dial: ang in degrees, 0 = up (12 o'clock), clockwise positive.
const pt = (cx, cy, r, ang) => {
const a = (ang - 90) * Math.PI / 180;
return [cx + r * Math.cos(a), cy + r * Math.sin(a)];
};
function Bezel({ title, children }) {
return (
<div className="vfr-gauge">
<svg viewBox="0 0 200 200">
<circle cx="100" cy="100" r="99" fill="#0c0d0f" />
<circle cx="100" cy="100" r="95" fill="#161a1f" stroke="#2a2f36" strokeWidth="2" />
<circle cx="100" cy="100" r="88" fill="#08090b" stroke="#000" strokeWidth="1" />
{children}
</svg>
<span className="vfr-name">{title}</span>
</div>
);
}
const Needle = ({ ang, len = 70, w = 4, color = '#fff', tail = 14 }) => (
<g transform={`rotate(${ang} 100 100)`}>
<line x1="100" y1={100 + tail} x2="100" y2={100 - len} stroke={color} strokeWidth={w} strokeLinecap="round" />
</g>
);
// generic tick ring
function ticks(min, max, a0, a1, step, big = 1, r = 84, lab) {
const out = [];
let i = 0;
for (let v = min; v <= max + 1e-6; v += step, i++) {
const ang = a0 + ((v - min) / (max - min)) * (a1 - a0);
const isBig = i % big === 0;
const [x1, y1] = pt(100, 100, r, ang);
const [x2, y2] = pt(100, 100, r - (isBig ? 12 : 7), ang);
out.push(<line key={'t' + v} x1={x1} y1={y1} x2={x2} y2={y2} stroke="#cfd6dd" strokeWidth={isBig ? 2 : 1} />);
if (lab && isBig) {
const [lx, ly] = pt(100, 100, r - 24, ang);
out.push(<text key={'l' + v} x={lx} y={ly + 4} textAnchor="middle" fill="#e7edf2" fontSize="12" fontFamily="'Saira Condensed',monospace">{lab(v)}</text>);
}
}
return out;
}
/* ---------- Airspeed ---------- */
function ASI({ V }) {
const kt = num(V.airspeed);
const A0 = -150, A1 = 150, MIN = 0, MAX = 200;
const ang = A0 + (clamp(kt, MIN, MAX) - MIN) / (MAX - MIN) * (A1 - A0);
const arc = (lo, hi, color, rr, wdt) => {
const [x1, y1] = pt(100, 100, rr, A0 + (lo / MAX) * (A1 - A0));
const [x2, y2] = pt(100, 100, rr, A0 + (hi / MAX) * (A1 - A0));
const large = ((hi - lo) / MAX) * 300 > 180 ? 1 : 0;
return <path d={`M${x1} ${y1} A${rr} ${rr} 0 ${large} 1 ${x2} ${y2}`} fill="none" stroke={color} strokeWidth={wdt} />;
};
return (
<Bezel title="AIRSPEED">
{arc(33, 85, '#fff', 70, 4)} {/* white flap arc */}
{arc(48, 129, '#21d04a', 78, 5)} {/* green normal */}
{arc(129, 163, '#e6c200', 78, 5)}{/* yellow caution */}
{(() => { const [x, y] = pt(100, 100, 78, A0 + (163 / MAX) * (A1 - A0)); const [x2, y2] = pt(100, 100, 70, A0 + (163 / MAX) * (A1 - A0)); return <line x1={x} y1={y} x2={x2} y2={y2} stroke="#e23" strokeWidth="4" />; })()}
{ticks(0, 200, A0, A1, 10, 2, 84, (v) => (v % 20 === 0 && v >= 40 ? v : ''))}
<text x="100" y="150" textAnchor="middle" fill="#9aa" fontSize="11" fontFamily="monospace">KT</text>
<Needle ang={ang} />
<circle cx="100" cy="100" r="7" fill="#ddd" />
</Bezel>
);
}
/* ---------- Attitude ---------- */
function AI({ V }) {
const pitch = num(V.pitch), roll = num(V.roll);
const PPD = 2.0; // px per degree pitch
const off = clamp(pitch, -25, 25) * PPD;
return (
<Bezel title="ATTITUDE">
<defs>
<clipPath id="aiclip"><circle cx="100" cy="100" r="86" /></clipPath>
</defs>
<g clipPath="url(#aiclip)">
<g transform={`rotate(${-roll} 100 100)`}>
<g transform={`translate(0 ${off})`}>
<rect x="-60" y="-120" width="320" height="220" fill="#4a90d9" />
<rect x="-60" y="100" width="320" height="220" fill="#7a5230" />
<line x1="-60" y1="100" x2="260" y2="100" stroke="#fff" strokeWidth="2" />
{[-20, -10, 10, 20].map((d) => (
<g key={d}>
<line x1={100 - (d % 20 === 0 ? 26 : 16)} y1={100 - d * PPD} x2={100 + (d % 20 === 0 ? 26 : 16)} y2={100 - d * PPD} stroke="#fff" strokeWidth="1.5" />
{d % 20 === 0 && <text x={100 - 34} y={100 - d * PPD + 4} fill="#fff" fontSize="9" textAnchor="middle">{Math.abs(d)}</text>}
</g>
))}
</g>
</g>
{/* bank arc + pointer */}
<g transform={`rotate(${-roll} 100 100)`}>
{[-60, -30, -20, -10, 0, 10, 20, 30, 60].map((b) => { const [x1, y1] = pt(100, 100, 84, b); const [x2, y2] = pt(100, 100, 78, b); return <line key={b} x1={x1} y1={y1} x2={x2} y2={y2} stroke="#fff" strokeWidth={b % 30 === 0 ? 2 : 1} />; })}
<polygon points="100,16 95,26 105,26" fill="#fff" />
</g>
</g>
{/* fixed aircraft reference */}
<polygon points="100,12 94,22 106,22" fill="#ffb300" />
<path d="M60 100 h22 l0 8 M140 100 h-22 l0 8" fill="none" stroke="#ffb300" strokeWidth="4" />
<rect x="97" y="97" width="6" height="6" fill="#ffb300" />
</Bezel>
);
}
/* ---------- Altimeter (3-pointer) ---------- */
function ALT({ V }) {
const alt = num(V.altitude), baro = num(V.baro, 29.92);
const a100 = (alt % 1000) / 1000 * 360;
const a1000 = (alt % 10000) / 10000 * 360;
const a10000 = (alt % 100000) / 100000 * 360;
return (
<Bezel title="ALTITUDE">
{ticks(0, 1000, 0, 360, 100, 1, 84, (v) => (v < 1000 ? v / 100 : ''))}
{ticks(0, 1000, 0, 360, 20, 5, 84)}
<rect x="118" y="92" width="42" height="16" fill="#000" stroke="#444" rx="2" />
<text x="139" y="104" textAnchor="middle" fill="#fff" fontSize="11" fontFamily="monospace">{baro.toFixed(2)}</text>
{/* 10000 ft thin pointer */}
<Needle ang={a10000} len={80} w={2} color="#ccc" tail={6} />
{/* 1000 ft short fat */}
<Needle ang={a1000} len={48} w={7} color="#fff" tail={10} />
{/* 100 ft long */}
<Needle ang={a100} len={78} w={4} color="#fff" tail={14} />
<circle cx="100" cy="100" r="7" fill="#ddd" />
</Bezel>
);
}
/* ---------- Turn coordinator ---------- */
function TC({ V }) {
const roll = num(V.roll), slip = num(V.slip);
const bank = clamp(roll, -30, 30); // little-plane bank (approx turn rate)
const ballX = 100 + clamp(slip, -8, 8) * 3.0;
return (
<Bezel title="TURN COORD.">
{/* standard-rate marks */}
{[-25, 25].map((b) => { const [x1, y1] = pt(100, 100, 80, b); const [x2, y2] = pt(100, 100, 66, b); return <line key={b} x1={x1} y1={y1} x2={x2} y2={y2} stroke="#fff" strokeWidth="2" />; })}
<text x="42" y="96" fill="#fff" fontSize="11">L</text>
<text x="150" y="96" fill="#fff" fontSize="11">R</text>
{/* little airplane */}
<g transform={`rotate(${bank} 100 100)`}>
<line x1="40" y1="100" x2="160" y2="100" stroke="#fff" strokeWidth="4" />
<line x1="100" y1="100" x2="100" y2="78" stroke="#fff" strokeWidth="4" />
<circle cx="100" cy="100" r="5" fill="#fff" />
</g>
<text x="100" y="150" textAnchor="middle" fill="#9aa" fontSize="9">2 MIN</text>
{/* inclinometer (slip ball) */}
<path d="M78 168 a22 22 0 0 1 44 0" fill="none" stroke="#444" strokeWidth="1" />
<rect x="92" y="160" width="2" height="12" fill="#666" /><rect x="106" y="160" width="2" height="12" fill="#666" />
<circle cx={ballX} cy="167" r="5" fill="#cfd6dd" />
</Bezel>
);
}
/* ---------- Heading indicator ---------- */
function HI({ V }) {
const hdg = ((num(V.heading) % 360) + 360) % 360;
const card = [];
for (let d = 0; d < 360; d += 5) {
const big = d % 30 === 0;
const [x1, y1] = pt(100, 100, 84, d);
const [x2, y2] = pt(100, 100, 84 - (big ? 12 : 7), d);
card.push(<line key={d} x1={x1} y1={y1} x2={x2} y2={y2} stroke="#fff" strokeWidth={big ? 2 : 1} />);
if (big) {
const [lx, ly] = pt(100, 100, 62, d);
const lbl = d === 0 ? 'N' : d === 90 ? 'E' : d === 180 ? 'S' : d === 270 ? 'W' : d / 10;
card.push(<text key={'l' + d} x={lx} y={ly + 4} textAnchor="middle" fill="#fff" fontSize="13" fontFamily="'Saira Condensed',monospace">{lbl}</text>);
}
}
return (
<Bezel title="HEADING">
<g transform={`rotate(${-hdg} 100 100)`}>{card}</g>
{/* fixed aircraft */}
<line x1="100" y1="64" x2="100" y2="136" stroke="#ffb300" strokeWidth="3" />
<line x1="78" y1="100" x2="122" y2="100" stroke="#ffb300" strokeWidth="3" />
<polygon points="100,18 95,28 105,28" fill="#0ff" />
</Bezel>
);
}
/* ---------- Vertical speed ---------- */
function VSI({ V }) {
const vs = clamp(num(V.vspeed), -2000, 2000);
// 0 at 9 o'clock (270°), climb sweeps up (toward 0/up), descent down.
const ang = 270 + (vs / 2000) * 160; // -2000→110°, 0→270°, +2000→430°(=70°)
return (
<Bezel title="VERT SPEED">
{ticks(-2000, 2000, 110, 430, 500, 1, 84, (v) => Math.abs(v) / 1000)}
{ticks(-2000, 2000, 110, 430, 100, 5, 84)}
<text x="100" y="150" textAnchor="middle" fill="#9aa" fontSize="9">FPM x1000</text>
<text x="64" y="104" fill="#fff" fontSize="10">0</text>
<Needle ang={ang} len={78} w={3} />
<circle cx="100" cy="100" r="6" fill="#ddd" />
</Bezel>
);
}
/* ---------- small gauges for the engine/fuel cluster ---------- */
const KG_GAL = 2.72;
function SmallBezel({ title, children }) {
return (
<div className="vfr-sg">
<svg viewBox="0 0 120 120">
<circle cx="60" cy="60" r="59" fill="#0c0d0f" />
<circle cx="60" cy="60" r="56" fill="#141414" stroke="#2a2f36" strokeWidth="1.5" />
{children}
</svg>
<span className="vfr-sname">{title}</span>
</div>
);
}
// dual half-dial gauge: left needle (lower-left sweep) + right needle (lower-right)
function Dual({ title, l, r }) {
const sg = (v, min, max, a0, a1) => a0 + (clamp(v, min, max) - min) / (max - min) * (a1 - a0);
const sp = (cx, cy, rr, ang) => { const a = (ang - 90) * Math.PI / 180; return [cx + rr * Math.cos(a), cy + rr * Math.sin(a)]; };
const band = (lo, hi, min, max, a0, a1, color) => {
const [x1, y1] = sp(60, 60, 46, sg(lo, min, max, a0, a1));
const [x2, y2] = sp(60, 60, 46, sg(hi, min, max, a0, a1));
return <path d={`M${x1} ${y1} A46 46 0 0 1 ${x2} ${y2}`} fill="none" stroke={color} strokeWidth="3" />;
};
const La = sg(l.value, l.min, l.max, -150, -20), Ra = sg(r.value, r.min, r.max, 20, 150);
return (
<SmallBezel title={title}>
{l.green && band(l.green[0], l.green[1], l.min, l.max, -150, -20, '#21d04a')}
{r.green && band(r.green[0], r.green[1], r.min, r.max, 20, 150, '#21d04a')}
<text x="30" y="40" fill="#9aa" fontSize="8" textAnchor="middle">{l.tag}</text>
<text x="90" y="40" fill="#9aa" fontSize="8" textAnchor="middle">{r.tag}</text>
<g transform={`rotate(${La} 60 60)`}><line x1="60" y1="64" x2="60" y2="22" stroke="#fff" strokeWidth="2.5" strokeLinecap="round" /></g>
<g transform={`rotate(${Ra} 60 60)`}><line x1="60" y1="64" x2="60" y2="22" stroke="#fff" strokeWidth="2.5" strokeLinecap="round" /></g>
<circle cx="60" cy="60" r="5" fill="#ccc" />
</SmallBezel>
);
}
/* ---------- tachometer ---------- */
function Tach({ V }) {
const rpm = arr0(V.engRpm);
const A0 = -150, A1 = 150;
const ang = A0 + clamp(rpm, 0, 3500) / 3500 * (A1 - A0);
return (
<Bezel title="TACHOMETER">
{ticks(0, 3500, A0, A1, 500, 1, 84, (v) => v / 100)}
{(() => { const [x1, y1] = pt(100, 100, 84, A0 + 2700 / 3500 * (A1 - A0)); const [x2, y2] = pt(100, 100, 72, A0 + 2700 / 3500 * (A1 - A0)); return <line x1={x1} y1={y1} x2={x2} y2={y2} stroke="#e23" strokeWidth="3" />; })()}
{(() => { const [x1, y1] = pt(100, 100, 80, A0 + 2100 / 3500 * (A1 - A0)); const [x2, y2] = pt(100, 100, 80, A0 + 2600 / 3500 * (A1 - A0)); return <path d={`M${x1} ${y1} A80 80 0 0 1 ${x2} ${y2}`} fill="none" stroke="#21d04a" strokeWidth="4" />; })()}
<text x="100" y="128" textAnchor="middle" fill="#9aa" fontSize="10">RPM x100</text>
<text x="100" y="150" textAnchor="middle" fill="#cfd6dd" fontSize="11" fontFamily="monospace">{Math.round(rpm)}</text>
<Needle ang={ang} />
<circle cx="100" cy="100" r="7" fill="#ddd" />
</Bezel>
);
}
/* ---------- digital OAT / Volts plate ---------- */
function Clock({ V }) {
const oatC = num(V.oat), oatF = Math.round(oatC * 9 / 5 + 32);
const volts = arr0(V.volts, 28);
return (
<div className="vfr-clock">
<div className="vc-row"><b>{oatF}°F</b><span>O.A.T.</span></div>
<div className="vc-row"><b>{volts.toFixed(1)}</b><span>VOLT</span></div>
</div>
);
}
export default function VFR({ values: V }) {
const fuelL = arr0(V.fuelQty, 0) / KG_GAL, fuelR = (Array.isArray(V.fuelQty) ? num(V.fuelQty[1]) : 0) / KG_GAL;
const oilF = arr0(V.oilTemp) * 9 / 5 + 32, oilP = arr0(V.oilPress);
const egtF = arr0(V.egt) * 9 / 5 + 32, ffGph = (arr0(V.fuelFlow) * 3600) / KG_GAL;
const amps = arr0(V.amps);
return (
<div className="vfr-panel">
<div className="vfr-layout">
<div className="vfr-cluster">
<Clock V={V} />
<Dual title="FUEL QTY" l={{ value: fuelL, min: 0, max: 26, green: [5, 26], tag: 'L' }} r={{ value: fuelR, min: 0, max: 26, green: [5, 26], tag: 'R' }} />
<Dual title="OIL" l={{ value: oilF, min: 75, max: 250, green: [100, 245], tag: '°F' }} r={{ value: oilP, min: 0, max: 115, green: [25, 100], tag: 'PSI' }} />
<Dual title="EGT · FF" l={{ value: egtF, min: 800, max: 1650, tag: 'EGT' }} r={{ value: ffGph, min: 0, max: 20, green: [0, 17], tag: 'GPH' }} />
<Dual title="VAC · AMP" l={{ value: 5, min: 0, max: 10, green: [4.5, 5.5], tag: 'SUC' }} r={{ value: amps, min: -60, max: 60, green: [0, 60], tag: 'AMP' }} />
</div>
<div className="vfr-main">
<div className="vfr-grid">
<ASI V={V} /><AI V={V} /><ALT V={V} />
<TC V={V} /><HI V={V} /><VSI V={V} />
</div>
<div className="vfr-tach"><Tach V={V} /></div>
</div>
</div>
</div>
);
}