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>
This commit is contained in:
@@ -0,0 +1,121 @@
|
||||
import React, { useState, useEffect } from 'react';
|
||||
import { num, navSearch } from '../api/useXplane.js';
|
||||
|
||||
const R_NM = 3440.065;
|
||||
const rad = (d) => (d * Math.PI) / 180;
|
||||
const deg = (r) => (r * 180) / Math.PI;
|
||||
function distNm(a, b) {
|
||||
const dLat = rad(b.lat - a.lat), dLon = rad(b.lon - a.lon);
|
||||
const s = Math.sin(dLat / 2) ** 2 + Math.cos(rad(a.lat)) * Math.cos(rad(b.lat)) * Math.sin(dLon / 2) ** 2;
|
||||
return 2 * R_NM * Math.asin(Math.min(1, Math.sqrt(s)));
|
||||
}
|
||||
function bearing(a, b) {
|
||||
const y = Math.sin(rad(b.lon - a.lon)) * Math.cos(rad(b.lat));
|
||||
const x = Math.cos(rad(a.lat)) * Math.sin(rad(b.lat)) -
|
||||
Math.sin(rad(a.lat)) * Math.cos(rad(b.lat)) * Math.cos(rad(b.lon - a.lon));
|
||||
return (deg(Math.atan2(y, x)) + 360) % 360;
|
||||
}
|
||||
|
||||
export default function FMS({ xp }) {
|
||||
const { flightPlan, fp, values, exportMsg } = xp;
|
||||
const wps = flightPlan.waypoints || [];
|
||||
const [entry, setEntry] = useState('');
|
||||
const [hits, setHits] = useState([]);
|
||||
|
||||
// live ident search against X-Plane's nav database
|
||||
useEffect(() => {
|
||||
const q = entry.trim();
|
||||
if (q.length < 2 || /[,\s]/.test(q)) { setHits([]); return; }
|
||||
let alive = true;
|
||||
navSearch(q).then((r) => alive && setHits(r.slice(0, 6)));
|
||||
return () => { alive = false; };
|
||||
}, [entry]);
|
||||
|
||||
const add = (id) => { fp.add(id || entry.trim()); setEntry(''); setHits([]); };
|
||||
|
||||
let total = 0;
|
||||
const rows = wps.map((w, i) => {
|
||||
const prev = wps[i - 1];
|
||||
const d = prev ? distNm(prev, w) : 0;
|
||||
const brg = prev ? bearing(prev, w) : null;
|
||||
total += d;
|
||||
return { w, i, d, brg };
|
||||
});
|
||||
|
||||
const gs = num(values.groundspeed) * 1.94384;
|
||||
const ete = gs > 20 ? total / gs : null; // hours
|
||||
const active = Math.max(1, Math.min(wps.length - 1, flightPlan?.activeLeg ?? 1));
|
||||
|
||||
return (
|
||||
<div className="fms">
|
||||
<div className="fms-head">
|
||||
<span>FLIGHT PLAN</span>
|
||||
<span className="fms-total">
|
||||
{total.toFixed(0)} NM{ete ? ` · ETE ${fmtHrs(ete)}` : ''}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<div className="fms-rows">
|
||||
<div className="fms-row fms-colhead">
|
||||
<span>#</span><span>WPT</span><span>DTK</span><span>DIST</span><span></span>
|
||||
</div>
|
||||
{rows.length === 0 && <div className="fms-empty">Kein Flugplan — Wegpunkt eingeben oder auf der Map tippen.</div>}
|
||||
{rows.map(({ w, i, d, brg }) => (
|
||||
<div className={`fms-row ${i === 0 ? 'orig' : ''} ${i === active ? 'active' : ''}`} key={i}
|
||||
onClick={() => i >= 1 && fp.setActive(i)} title={i >= 1 ? 'Als aktives Bein setzen' : ''}>
|
||||
<span className="idx">{i + 1}</span>
|
||||
<span className="wid">{w.id}<i className="wtype">{w.type}</i></span>
|
||||
<span className="dtk">{brg == null ? '—' : `${String(Math.round(brg)).padStart(3, '0')}°`}</span>
|
||||
<span className="dist">{i === 0 ? 'ORIG' : `${d.toFixed(1)}`}</span>
|
||||
<button className="del" onClick={(e) => { e.stopPropagation(); fp.remove(i); }}>✕</button>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
|
||||
<div className="fms-scratch">
|
||||
{hits.length > 0 && (
|
||||
<div className="fms-hits">
|
||||
{hits.map((h) => (
|
||||
<button key={h.id + h.lat} onClick={() => add(h.id)}>
|
||||
<b>{h.id}</b> <i>{h.type}</i> <span>{h.lat.toFixed(2)}, {h.lon.toFixed(2)}</span>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
<div className="fms-input">
|
||||
<input
|
||||
value={entry}
|
||||
onChange={(e) => setEntry(e.target.value.toUpperCase())}
|
||||
onKeyDown={(e) => e.key === 'Enter' && add()}
|
||||
placeholder="IDENT (z.B. KSEA, SEA) oder LAT,LON"
|
||||
autoCapitalize="characters" autoCorrect="off" spellCheck="false"
|
||||
/>
|
||||
<button className="fbtn add" onClick={() => add()}>ADD</button>
|
||||
</div>
|
||||
<div className="fms-actions">
|
||||
<button className="fbtn" onClick={() => fp.clear()} disabled={!wps.length}>CLEAR</button>
|
||||
<button className="fbtn export" onClick={() => fp.export('WEBFPL')} disabled={wps.length < 2}>
|
||||
EXPORT → X-PLANE (.fms)
|
||||
</button>
|
||||
</div>
|
||||
{exportMsg && (
|
||||
<div className={`fms-export ${exportMsg.ok ? 'ok' : 'err'}`}>
|
||||
{exportMsg.ok
|
||||
? (exportMsg.intoXplane
|
||||
? `✓ Gespeichert in X-Plane: ${shorten(exportMsg.file)} — im Flieger-FMS unter „Load" wählen.`
|
||||
: `✓ Datei geschrieben: ${shorten(exportMsg.file)} (X-Plane-Ordner nicht gefunden — XPLANE_ROOT setzen).`)
|
||||
: `✗ ${exportMsg.error}`}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function fmtHrs(h) {
|
||||
const m = Math.round(h * 60);
|
||||
return `${Math.floor(m / 60)}:${String(m % 60).padStart(2, '0')}`;
|
||||
}
|
||||
function shorten(p) {
|
||||
return p && p.length > 48 ? '…' + p.slice(-46) : p;
|
||||
}
|
||||
Reference in New Issue
Block a user