G1000: two-way sim sync, more PFD/MFD fidelity, authentic dialogs

Sync (FlyWithLua companions in plugins/ + server/fmssync.js):
- FMS flight-plan two-way sync (App <-> in-sim FMS) via fms-sync.lua
- G1000 UI-state publish (page/range/inset) via ui-sync.lua + CDI source,
  baro, map-range follow
- Terrain awareness: elevation grid probe (terrain-probe.lua) -> red/yellow
  MFD overlay vs aircraft altitude

PFD:
- AFCS mode annunciation bar from autopilot _status datarefs
- CDI source GPS/VLOC colouring, BRG1/BRG2 pointers + DME windows, marker beacons
- magenta speed/altitude trend vectors, selected-altitude alerting
- time-based (frame-rate-independent) smoothing for attitude/heading/tapes

MFD:
- nav data bar (DTK/ETE/active leg), airways overlay from earth_awy.dat,
  compass rose anchored to the ownship

Dialogs (NEAREST/FLIGHTPLAN/DIRECT-TO/PROCEDURES):
- flat, square, embedded G1000 look (no shadow/rounded/transparency)
- compact lower-right placement, no close X (softkey toggles), single window
- NEAREST 2-line entries (ILS/VFR, COM freq, runway length), PROC action menu

Service worker: network-first HTML so reloads pick up new builds (cache v2).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
2026-06-02 02:17:06 +02:00
parent 354ea5d44b
commit 38b048ad41
23 changed files with 1707 additions and 213 deletions
+69 -21
View File
@@ -1,26 +1,67 @@
import React, { useState } from 'react';
import { num } from '../api/useXplane.js';
import MapView from './MapView.jsx';
import Nearest from './Nearest.jsx';
import FplPage from './FplPage.jsx';
const arr = (v, i = 0, d = 0) => (Array.isArray(v) ? num(v[i], d) : num(v, d));
const KG_PER_GAL = 2.72; // avgas
const navF = (v) => (num(v) / 100).toFixed(2);
const comF = (v) => (num(v) / 100).toFixed(3);
// Active flight-plan leg: distance / desired track / ETE to the active waypoint
// (great-circle from the aircraft), for the MFD nav data bar. Mirrors the PFD's
// activeNav so the two displays agree.
const R_NM = 3440.065, D2R = Math.PI / 180, R2D = 180 / Math.PI;
function legNav(V, fp) {
const wps = fp?.waypoints || [];
const ai = Math.max(1, Math.min(wps.length - 1, fp?.activeLeg ?? 1));
const wp = wps[ai];
const lat = num(V.lat), lon = num(V.lon);
if (!wp || (!lat && !lon)) return null;
const dLat = (wp.lat - lat) * D2R, dLon = (wp.lon - lon) * D2R;
const a = Math.sin(dLat / 2) ** 2 + Math.cos(lat * D2R) * Math.cos(wp.lat * D2R) * Math.sin(dLon / 2) ** 2;
const dist = 2 * R_NM * Math.asin(Math.min(1, Math.sqrt(a)));
const y = Math.sin(dLon) * Math.cos(wp.lat * D2R);
const x = Math.cos(lat * D2R) * Math.sin(wp.lat * D2R) - Math.sin(lat * D2R) * Math.cos(wp.lat * D2R) * Math.cos(dLon);
const dtk = (Math.atan2(y, x) * R2D + 360) % 360;
const gs = num(V.groundspeed) * 1.94384;
return { id: wp.id, dist, dtk, ete: gs > 20 ? (dist / gs) * 3600 : null };
}
const fmtEte = (s) => {
if (s == null) return '__:__';
const m = Math.floor(s / 60), ss = Math.round(s % 60);
return m < 60 ? `${m}:${String(ss).padStart(2, '0')}` : `${Math.floor(m / 60)}+${String(m % 60).padStart(2, '0')}`;
};
// G1000 MFD — full-width NAV/COM bar on top, the engine instrument strip (EIS)
// down the left as real bar gauges, and the moving map (X-Plane nav data) with
// G1000 chrome (compass rose, range, NORTH UP, mode) filling the rest.
export default function MFD({ values: V, flightPlan, fp, mapMode }) {
const MFD_PAGES = [{ id: 'map', name: 'MAP' }, { id: 'fpl', name: 'FPL' }, { id: 'nrst', name: 'NRST' }];
export default function MFD({ values: V, flightPlan, fp, mapMode, page = 'map', onCycle, xp }) {
const [rangeNm, setRangeNm] = useState(8);
const idx = Math.max(0, MFD_PAGES.findIndex((p) => p.id === page));
return (
<div className="mfd-g1000">
<MfdTopBar V={V} />
<MfdTopBar V={V} fp={flightPlan} />
<div className="mfd-body">
<EisStrip V={V} />
<div className="mfd-map">
<MapView values={V} flightPlan={flightPlan} fp={fp} hud={false}
mapMode={mapMode} dcltr={mapMode?.dcltr || 0} onView={({ rangeNm }) => setRangeNm(rangeNm)} />
<MapChrome V={V} rangeNm={rangeNm} />
{/* MapView stays mounted (keeps tiles warm) but is hidden under NRST */}
<div style={{ position: 'absolute', inset: 0, visibility: page === 'map' ? 'visible' : 'hidden' }}>
<MapView values={V} flightPlan={flightPlan} fp={fp} hud={false}
mapMode={mapMode} dcltr={mapMode?.dcltr || 0} rangeNm={num(V.uiMapRange) || undefined}
terrain={xp?.terrain} rose onView={({ rangeNm }) => setRangeNm(rangeNm)} />
<MapChrome V={V} rangeNm={rangeNm} />
</div>
{page === 'nrst' && <Nearest values={V} full />}
{page === 'fpl' && xp && <FplPage xp={xp} full />}
{/* page-group indicator (bottom-right), like the real G1000 — selected
by the FMS knob; tappable as a touch fallback. */}
<button className="mfd-pageind" onClick={() => onCycle && onCycle(1)} title="Seite (FMS-Knopf)">
<span>{MFD_PAGES[idx].name}</span>
{MFD_PAGES.map((p, i) => <em key={p.id} className={i === idx ? 'on' : ''} />)}
</button>
</div>
</div>
</div>
@@ -28,9 +69,11 @@ export default function MFD({ values: V, flightPlan, fp, mapMode }) {
}
/* ---------------- top NAV/COM bar ---------------- */
function MfdTopBar({ V }) {
function MfdTopBar({ V, fp }) {
const gs = Math.round(num(V.groundspeed) * 1.94384);
const trk = String(Math.round(num(V.track)) % 360).padStart(3, '0');
const leg = legNav(V, fp);
const dtk = leg ? `${String(Math.round(leg.dtk) % 360).padStart(3, '0')}°` : '___°';
const swap = (x, y) => <text x={x} y={y} fill="#0ff" fontSize="16" textAnchor="middle"></text>;
return (
<svg className="mfd-topbar" viewBox="0 0 1000 70" preserveAspectRatio="none" fontFamily="monospace">
@@ -51,10 +94,22 @@ function MfdTopBar({ V }) {
<text x="350" y="27" fill="#e040fb" fontSize="15" fontWeight="bold">{gs}</text>
<text x="378" y="27" fill="#0c9" fontSize="11">KT</text>
<text x="410" y="27" fill="#fff" fontSize="13">DTK</text>
<text x="448" y="27" fill="#e040fb" fontSize="15" fontWeight="bold">{dtk}</text>
<text x="520" y="27" fill="#fff" fontSize="13">TRK</text>
<text x="560" y="27" fill="#e040fb" fontSize="15" fontWeight="bold">{trk}°</text>
<text x="610" y="27" fill="#fff" fontSize="13">ETE</text>
<text x="480" y="58" fill="#0ff" fontSize="15" textAnchor="middle">NAV DEFAULT NAV</text>
<text x="648" y="27" fill="#fff" fontSize="15">{fmtEte(leg?.ete)}</text>
{/* active leg (centre): → waypoint + distance, or no-flight-plan note */}
{leg ? (
<g>
<text x="412" y="58" fill="#e040fb" fontSize="16"></text>
<text x="432" y="58" fill="#fff" fontSize="16" fontWeight="bold">{leg.id}</text>
<text x="520" y="58" fill="#0ff" fontSize="15">{leg.dist.toFixed(1)}</text>
<text x="566" y="58" fill="#0c9" fontSize="11">NM</text>
</g>
) : (
<text x="480" y="58" fill="#777" fontSize="14" textAnchor="middle">NO ACTIVE WAYPOINT</text>
)}
{/* COM1 / COM2 */}
<text x="690" y="27" fill="#0f0" fontSize="17">{comF(V.com1)}</text>
{swap(818, 27)}
@@ -191,24 +246,17 @@ function niceRange(nm) { let r = NICE[0]; for (const s of NICE) if (nm >= s) r =
function MapChrome({ V, rangeNm }) {
const gs = Math.round(num(V.groundspeed) * 1.94384);
const rng = niceRange(rangeNm);
const cx = 160, cy = 160, r = 150;
const ticks = [];
for (let d = 0; d < 360; d += 10) {
const a = ((d - 90) * Math.PI) / 180;
const big = d % 30 === 0;
const r2 = r - (big ? 12 : 7);
ticks.push(<line key={d} x1={cx + r * Math.cos(a)} y1={cy + r * Math.sin(a)} x2={cx + r2 * Math.cos(a)} y2={cy + r2 * Math.sin(a)} stroke="#cfd6dd" strokeWidth={big ? 2 : 1} />);
if (big) {
const lbl = d === 0 ? 'N' : d === 90 ? 'E' : d === 180 ? 'S' : d === 270 ? 'W' : d / 10;
ticks.push(<text key={'l' + d} x={cx + (r - 26) * Math.cos(a)} y={cy + (r - 26) * Math.sin(a) + 5} fill="#fff" fontSize="15" textAnchor="middle" fontFamily="monospace">{lbl}</text>);
}
}
const wd = ((Math.round(num(V.windDir)) % 360) + 360) % 360, ws = Math.round(num(V.windSpd));
return (
<div className="map-chrome">
<svg className="map-rose" viewBox="0 0 320 320">{ticks}</svg>
{/* the compass rose now lives in MapView, anchored to the aircraft */}
<div className="mc-tr"><b>{gs} KT</b><span>NORTH UP</span></div>
<div className="mc-wind">
{ws >= 1
? (<><span className="mc-windarr" style={{ transform: `rotate(${wd + 180}deg)` }}></span><span>{String(wd).padStart(3, '0')}° {ws}<i>kt</i></span></>)
: <span>CALM</span>}
</div>
<div className="mc-range">{rng} NM</div>
<div className="mc-mode">NAV <em className="on" /><em /><em /><em /><em /></div>
</div>
);
}