474a35c6e3
The PROFILE softkey was dead. Now it overlays a vertical profile at the bottom of the MFD map: own aircraft at the left, upcoming waypoints with their target altitudes, the magenta planned descent path, and an altitude grid — pure geometry from the active flight plan + current altitude. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
331 lines
18 KiB
React
331 lines
18 KiB
React
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.
|
||
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, vnav, onVnav }) {
|
||
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} fp={flightPlan} />
|
||
<div className="mfd-body">
|
||
<EisStrip V={V} />
|
||
<div className="mfd-map">
|
||
{/* 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 === 'map' && mapMode?.profile && <VertProfile V={V} fp={flightPlan} />}
|
||
{page === 'nrst' && <Nearest xp={xp} full />}
|
||
{page === 'fpl' && xp && <FplPage xp={xp} full vnav={vnav} onVnav={onVnav} />}
|
||
{/* 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>
|
||
);
|
||
}
|
||
|
||
/* ---------------- top NAV/COM bar ---------------- */
|
||
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">
|
||
<rect x="0" y="0" width="1000" height="70" fill="#000" />
|
||
{[300, 660].map((x) => <line key={x} x1={x} y1="2" x2={x} y2="68" stroke="#333" strokeWidth="1.5" />)}
|
||
<line x1="0" y1="70" x2="1000" y2="70" stroke="#3a3a3a" strokeWidth="2" />
|
||
{/* NAV1 / NAV2 — standby LEFT (cyan, boxed), active RIGHT (white) per manual */}
|
||
<text x="10" y="27" fill="#fff" fontSize="13">NAV1</text>
|
||
<rect x="50" y="11" width="80" height="21" fill="none" stroke="#0ff" strokeWidth="1.3" />
|
||
<text x="126" y="27" fill="#0ff" fontSize="17" textAnchor="end">{navF(V.nav1Sb)}</text>
|
||
{swap(150, 27)}
|
||
<text x="174" y="27" fill="#fff" fontSize="17">{navF(V.nav1)}</text>
|
||
<text x="10" y="58" fill="#fff" fontSize="13">NAV2</text>
|
||
<text x="126" y="58" fill="#0ff" fontSize="17" textAnchor="end">{navF(V.nav2Sb)}</text>
|
||
<text x="174" y="58" fill="#fff" fontSize="17">{navF(V.nav2)}</text>
|
||
{/* centre: GS/DTK/TRK/ETE + active mode line */}
|
||
<text x="312" y="27" fill="#fff" fontSize="13">GS</text>
|
||
<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="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)}
|
||
<rect x="846" y="11" width="92" height="21" fill="none" stroke="#0ff" strokeWidth="1.3" />
|
||
<text x="936" y="27" fill="#0ff" fontSize="17" textAnchor="end">{comF(V.com1Sb)}</text>
|
||
<text x="994" y="27" fill="#fff" fontSize="12" textAnchor="end">COM1</text>
|
||
<text x="690" y="58" fill="#fff" fontSize="17">{comF(V.com2)}</text>
|
||
<text x="936" y="58" fill="#fff" fontSize="17" textAnchor="end">{comF(V.com2Sb)}</text>
|
||
<text x="994" y="58" fill="#fff" fontSize="12" textAnchor="end">COM2</text>
|
||
</svg>
|
||
);
|
||
}
|
||
|
||
/* ---------------- vertical profile (PROFILE softkey) ---------------- */
|
||
// Altitude-vs-distance view along the active flight plan: the aircraft at the
|
||
// left, upcoming waypoints with their target altitudes, and the planned descent
|
||
// path between them. Pure geometry from the plan + current altitude.
|
||
function VertProfile({ V, fp }) {
|
||
const R = 3440.065, rad = (d) => (d * Math.PI) / 180;
|
||
const dist = (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 * Math.asin(Math.min(1, Math.sqrt(s)));
|
||
};
|
||
const wps = fp?.waypoints || [];
|
||
const ai = Math.max(1, Math.min(wps.length - 1, fp?.activeLeg ?? 1));
|
||
const alt = num(V.altitude);
|
||
const pts = []; let cum = 0, prev = { lat: num(V.lat), lon: num(V.lon) };
|
||
for (let i = ai; i < wps.length; i++) { cum += dist(prev, wps[i]); prev = wps[i]; pts.push({ d: cum, alt: num(wps[i].alt) || null, id: wps[i].id }); }
|
||
const maxD = Math.max(10, cum);
|
||
const altMax = Math.max(alt, ...pts.map((p) => p.alt || 0), 3000) * 1.15;
|
||
const W = 1000, H = 200, padL = 46, padR = 12, padT = 12, padB = 22;
|
||
const x = (d) => padL + (d / maxD) * (W - padL - padR);
|
||
const y = (a) => (H - padB) - (a / altMax) * (H - padT - padB);
|
||
const altPts = pts.filter((p) => p.alt != null);
|
||
const path = [`${x(0)},${y(alt)}`, ...altPts.map((p) => `${x(p.d)},${y(p.alt)}`)].join(' ');
|
||
const grid = [0, altMax / 2, altMax].map((a) => Math.round(a / 500) * 500);
|
||
return (
|
||
<div className="vprof">
|
||
<svg viewBox={`0 0 ${W} ${H}`} preserveAspectRatio="none">
|
||
<rect x="0" y="0" width={W} height={H} fill="#05080b" />
|
||
{grid.map((a) => (
|
||
<g key={a}>
|
||
<line x1={padL} y1={y(a)} x2={W - padR} y2={y(a)} stroke="#1b2730" strokeWidth="1" />
|
||
<text x={padL - 5} y={y(a) + 4} fill="#6f808d" fontSize="11" textAnchor="end" fontFamily="monospace">{Math.round(a / 100) * 100}</text>
|
||
</g>
|
||
))}
|
||
{/* planned vertical path through waypoint target altitudes */}
|
||
{altPts.length > 0 && <polyline points={path} fill="none" stroke="#ff20ff" strokeWidth="2.5" />}
|
||
{/* waypoints */}
|
||
{pts.map((p, i) => (
|
||
<g key={p.id + i}>
|
||
<line x1={x(p.d)} y1={padT} x2={x(p.d)} y2={H - padB} stroke="#222d36" strokeWidth="1" />
|
||
{p.alt != null && <rect x={x(p.d) - 3} y={y(p.alt) - 3} width="6" height="6" fill="#4fa8ff" />}
|
||
<text x={x(p.d)} y={H - padB + 14} fill="#9fb3c0" fontSize="11" textAnchor="middle" fontFamily="monospace">{p.id}</text>
|
||
{p.alt != null && <text x={x(p.d)} y={y(p.alt) - 7} fill="#4fa8ff" fontSize="10" textAnchor="middle" fontFamily="monospace">{p.alt}</text>}
|
||
</g>
|
||
))}
|
||
{/* own aircraft at the left edge */}
|
||
<polygon points={`${x(0) - 7},${y(alt)} ${x(0) + 7},${y(alt) - 5} ${x(0) + 7},${y(alt) + 5}`} fill="#ffce00" />
|
||
<text x={x(0) + 10} y={y(alt) - 6} fill="#ffce00" fontSize="11" fontFamily="monospace">{Math.round(alt)}</text>
|
||
</svg>
|
||
</div>
|
||
);
|
||
}
|
||
|
||
/* ---------------- engine instrument strip (EIS) ---------------- */
|
||
function EisStrip({ V }) {
|
||
const rpm = arr(V.engRpm);
|
||
const ffGph = (arr(V.fuelFlow) * 3600) / KG_PER_GAL;
|
||
const oilPsi = arr(V.oilPress);
|
||
// 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 voltsM = arr(V.volts, 0, 28); // main bus
|
||
const voltsE = arr(V.volts, 1, voltsM); // essential bus (falls back to main)
|
||
const ampsM = arr(V.genAmps, 0); // alternator (M)
|
||
const ampsS = arr(V.amps, 0); // battery (S)
|
||
const engHrs = num(V.engHrs) / 3600;
|
||
return (
|
||
<svg className="eis-svg" viewBox="0 0 190 540" preserveAspectRatio="xMidYMin meet" fontFamily="monospace">
|
||
<rect x="0" y="0" width="190" height="540" fill="#0a0a0a" />
|
||
<RpmArc rpm={rpm} />
|
||
<Bar y={132} label="FFLOW GPH" val={ffGph.toFixed(1)} min={0} max={20} value={ffGph}
|
||
zones={[{ from: 0, to: 17, c: '#0c0' }, { from: 17, to: 20, c: '#c00' }]} />
|
||
<Bar y={170} label="OIL PSI" val={Math.round(oilPsi)} min={0} max={100} value={oilPsi}
|
||
zones={[{ from: 0, to: 20, c: '#c00' }, { from: 20, to: 100, c: '#0c0' }]} />
|
||
<Bar y={208} label="OIL °F" val={Math.round(oilF)} min={75} max={250} value={oilF}
|
||
zones={[{ from: 100, to: 245, c: '#0c0' }]} />
|
||
<Bar y={246} label="EGT °F" val={Math.round(egtF)} min={800} max={1650} value={egtF} zones={[]} />
|
||
<Bar y={284} label="VAC" min={0} max={10} value={5}
|
||
zones={[{ from: 4.5, to: 5.5, c: '#0c0' }]} />
|
||
<FuelBar y={330} left={fuelL} right={fuelR} />
|
||
{/* fuel totalizer (SYSTEM → DEC/INC/RST FUEL): pilot-set remaining + used */}
|
||
{(() => {
|
||
const rem = num(V.fuelTot) / KG_PER_GAL;
|
||
const used = Math.max(0, (num(V.fuelMax) - num(V.fuelTot)) / KG_PER_GAL);
|
||
return (<>
|
||
<text x="8" y="392" fill="#39d3c0" fontSize="11">CALC</text>
|
||
<text x="118" y="392" fill="#fff" fontSize="14" textAnchor="end">{rem.toFixed(1)}</text>
|
||
<text x="124" y="392" fill="#7f8c97" fontSize="10">GAL</text>
|
||
<text x="182" y="392" fill="#9aa" fontSize="10" textAnchor="end">USED {used.toFixed(0)}</text>
|
||
</>);
|
||
})()}
|
||
<text x="8" y="412" fill="#39d3c0" fontSize="12">ENG</text>
|
||
<text x="182" y="412" fill="#fff" fontSize="14" textAnchor="end">{engHrs.toFixed(1)} HRS</text>
|
||
<text x="95" y="438" fill="#39d3c0" fontSize="12" textAnchor="middle">– ELECTRICAL –</text>
|
||
<text x="20" y="462" fill="#fff" fontSize="12">M</text>
|
||
<text x="95" y="462" fill="#39d3c0" fontSize="12" textAnchor="middle">BUS</text>
|
||
<text x="170" y="462" fill="#fff" fontSize="12" textAnchor="end">E</text>
|
||
<text x="18" y="482" fill="#fff" fontSize="15">{voltsM.toFixed(1)}</text>
|
||
<text x="95" y="482" fill="#39d3c0" fontSize="11" textAnchor="middle">VOLTS</text>
|
||
<text x="172" y="482" fill="#fff" fontSize="15" textAnchor="end">{voltsE.toFixed(1)}</text>
|
||
<text x="20" y="506" fill="#fff" fontSize="12">M</text>
|
||
<text x="95" y="506" fill="#39d3c0" fontSize="12" textAnchor="middle">BATT</text>
|
||
<text x="170" y="506" fill="#fff" fontSize="12" textAnchor="end">S</text>
|
||
<text x="18" y="526" fill="#fff" fontSize="15">{ampsM >= 0 ? '+' : ''}{ampsM.toFixed(1)}</text>
|
||
<text x="95" y="526" fill="#39d3c0" fontSize="11" textAnchor="middle">AMPS</text>
|
||
<text x="172" y="526" fill="#fff" fontSize="15" textAnchor="end">{ampsS >= 0 ? '+' : ''}{ampsS.toFixed(1)}</text>
|
||
</svg>
|
||
);
|
||
}
|
||
|
||
function Bar({ y, label, val, min, max, value, zones }) {
|
||
const x0 = 8, x1 = 182, bw = x1 - x0;
|
||
const px = (v) => x0 + bw * Math.max(0, Math.min(1, (v - min) / (max - min)));
|
||
const p = px(value);
|
||
return (
|
||
<g>
|
||
<text x={x0} y={y} fill="#39d3c0" fontSize="12">{label}</text>
|
||
{val != null && <text x={x1} y={y} fill="#fff" fontSize="16" fontWeight="bold" textAnchor="end">{val}</text>}
|
||
<rect x={x0} y={y + 9} width={bw} height="5" fill="#2a2a2a" />
|
||
{zones.map((z, i) => <rect key={i} x={px(z.from)} y={y + 9} width={Math.max(0, px(z.to) - px(z.from))} height="5" fill={z.c} />)}
|
||
<polygon points={`${p},${y + 9} ${p - 5},${y + 1} ${p + 5},${y + 1}`} fill="#fff" stroke="#000" strokeWidth="0.5" />
|
||
</g>
|
||
);
|
||
}
|
||
|
||
// Fuel quantity: one bar per the C172's two tanks, with L and R pointers on a
|
||
// shared 0–10–20–F (gal) scale; yellow/red caution zone at the low end.
|
||
function FuelBar({ y, left, right }) {
|
||
const x0 = 8, x1 = 182, bw = x1 - x0, max = 26.5;
|
||
const px = (g) => x0 + bw * Math.max(0, Math.min(1, g / max));
|
||
const tick = (g, lbl) => (
|
||
<g key={lbl}>
|
||
<line x1={px(g)} y1={y + 16} x2={px(g)} y2={y + 20} stroke="#777" strokeWidth="1" />
|
||
<text x={px(g)} y={y + 31} fill="#aaa" fontSize="10" textAnchor="middle">{lbl}</text>
|
||
</g>
|
||
);
|
||
const ptr = (g, lbl) => (
|
||
<g>
|
||
<polygon points={`${px(g)},${y + 8} ${px(g) - 5},${y} ${px(g) + 5},${y}`} fill="#fff" stroke="#000" strokeWidth="0.5" />
|
||
<text x={px(g)} y={y - 2} fill="#fff" fontSize="9" textAnchor="middle">{lbl}</text>
|
||
</g>
|
||
);
|
||
return (
|
||
<g>
|
||
<text x={x0} y={y - 6} fill="#39d3c0" fontSize="12">FUEL QTY GAL</text>
|
||
<rect x={x0} y={y + 8} width={bw} height="6" fill="#2a2a2a" />
|
||
<rect x={px(0)} y={y + 8} width={px(2.5) - px(0)} height="6" fill="#c00" />
|
||
<rect x={px(2.5)} y={y + 8} width={px(5) - px(2.5)} height="6" fill="#dd0" />
|
||
<rect x={px(5)} y={y + 8} width={px(max) - px(5)} height="6" fill="#0c0" />
|
||
{tick(0, '0')}{tick(8.83, '10')}{tick(17.66, '20')}{tick(max, 'F')}
|
||
{ptr(left, 'L')}{ptr(right, 'R')}
|
||
</g>
|
||
);
|
||
}
|
||
|
||
function RpmArc({ rpm }) {
|
||
const max = 2700, frac = Math.max(0, Math.min(1, rpm / max));
|
||
const a0 = -210, a1 = 30, ang = a0 + (a1 - a0) * frac;
|
||
const cx = 95, cy = 62, r = 42;
|
||
const pt = (deg, rr) => [cx + rr * Math.cos((deg * Math.PI) / 180), cy + rr * Math.sin((deg * Math.PI) / 180)];
|
||
const arc = (s, e, color, w) => {
|
||
const [x0, y0] = pt(s, r), [x1, y1] = pt(e, r);
|
||
return <path d={`M${x0} ${y0} A${r} ${r} 0 ${e - s > 180 ? 1 : 0} 1 ${x1} ${y1}`} fill="none" stroke={color} strokeWidth={w} />;
|
||
};
|
||
const [nx, ny] = pt(ang, r - 2);
|
||
return (
|
||
<g fontFamily="monospace">
|
||
{arc(a0, a1, '#2a2a2a', 7)}
|
||
{arc(a0, -30, '#0c0', 7)}
|
||
{arc(0, a1, '#c00', 7)}
|
||
<line x1={cx} y1={cy} x2={nx} y2={ny} stroke="#fff" strokeWidth="2.5" />
|
||
<circle cx={cx} cy={cy} r="3" fill="#fff" />
|
||
<text x={cx} y={cy + 14} fill="#39d3c0" fontSize="12" textAnchor="middle">RPM</text>
|
||
<text x={cx} y={cy + 40} fill="#fff" fontSize="26" fontWeight="bold" textAnchor="middle">{Math.round(rpm)}</text>
|
||
</g>
|
||
);
|
||
}
|
||
|
||
/* ---------------- map chrome overlay (compass rose / range / mode) ---------------- */
|
||
const NICE = [0.5, 1, 1.5, 2, 2.5, 4, 5, 7.5, 10, 15, 20, 25, 40, 50, 75, 100, 150, 200, 250, 500];
|
||
function niceRange(nm) { let r = NICE[0]; for (const s of NICE) if (nm >= s) r = s; return r; }
|
||
|
||
function MapChrome({ V, rangeNm }) {
|
||
const gs = Math.round(num(V.groundspeed) * 1.94384);
|
||
const rng = niceRange(rangeNm);
|
||
const wd = ((Math.round(num(V.windDir)) % 360) + 360) % 360, ws = Math.round(num(V.windSpd));
|
||
return (
|
||
<div className="map-chrome">
|
||
{/* 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>
|
||
);
|
||
}
|