ebc33a78b7
- 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>
305 lines
14 KiB
React
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>
|
|
);
|
|
}
|