Compare commits
4 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 053d362245 | |||
| 033a9d406a | |||
| 38b048ad41 | |||
| 354ea5d44b |
Generated
+1
-1
@@ -5900,7 +5900,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "xplane-cockpit"
|
||||
version = "0.1.3"
|
||||
version = "0.1.4"
|
||||
dependencies = [
|
||||
"local-ip-address",
|
||||
"serde",
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
[package]
|
||||
name = "xplane-cockpit"
|
||||
version = "0.1.3"
|
||||
version = "0.1.4"
|
||||
description = "Desktop launcher for the X-Plane G1000 web cockpit"
|
||||
authors = ["karim"]
|
||||
edition = "2021"
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"$schema": "https://schema.tauri.app/config/2",
|
||||
"productName": "X-Plane Cockpit",
|
||||
"version": "0.1.3",
|
||||
"version": "0.1.4",
|
||||
"identifier": "ch.kgva.xplanecockpit",
|
||||
"build": {
|
||||
"frontendDist": "../ui"
|
||||
|
||||
@@ -0,0 +1,31 @@
|
||||
# FlyWithLua companion — FMS two-way sync
|
||||
|
||||
X-Plane's Web API can't write a flight plan into the FMS. `fms-sync.lua` runs
|
||||
inside X-Plane (via FlyWithLua, which has the FMS SDK) and syncs the shared
|
||||
cockpit plan ↔ the in-sim FMS through two files in `Output/fms-sync/`.
|
||||
|
||||
## Install (sim PC only)
|
||||
|
||||
1. Install **FlyWithLua NG+** (free): copy its plugin folder into
|
||||
`<X-Plane>/Resources/plugins/FlyWithLua/`.
|
||||
2. Copy the scripts into `<X-Plane>/Resources/plugins/FlyWithLua/Scripts/`:
|
||||
- **`fms-sync.lua`** — flight-plan two-way sync
|
||||
- **`ui-sync.lua`** — G1000 UI state (page / range / inset)
|
||||
- **`terrain-probe.lua`** — terrain-awareness elevation grid for the MFD
|
||||
3. Restart X-Plane (or *FlyWithLua → Reload all Lua script files*).
|
||||
The log shows `[glass-cockpit] FMS sync active`.
|
||||
|
||||
The bridge (desktop app / `node server/bridge.js`) must run on the **same PC**
|
||||
as X-Plane, so both see `<X-Plane>/Output/fms-sync/`.
|
||||
|
||||
## What you get
|
||||
|
||||
- **App → Sim:** load/build a plan in the web cockpit → it appears in the 3-D
|
||||
G1000 and the autopilot can fly it.
|
||||
- **Sim → App:** build/edit the plan in the real FMS → it shows on every tablet.
|
||||
- **Terrain:** the MFD TERRAIN map colours real scenery elevation red/yellow
|
||||
relative to your altitude (probed live by `terrain-probe.lua`).
|
||||
|
||||
A 3-decimal lat/lon signature de-dupes the round-trip, so the two sides never
|
||||
loop. Waypoints are pushed as lat/lon legs (exact route; in-sim idents are
|
||||
generic — route accuracy over cosmetics).
|
||||
@@ -0,0 +1,113 @@
|
||||
-- ============================================================================
|
||||
-- X-Plane Glass Cockpit — FMS two-way sync (FlyWithLua companion)
|
||||
-- ============================================================================
|
||||
-- The web cockpit's bridge can't write the FMS via X-Plane's Web API. This
|
||||
-- script runs INSIDE X-Plane (FlyWithLua) and has the FMS SDK, so it bridges
|
||||
-- the shared plan <-> the in-sim FMS through two text files:
|
||||
--
|
||||
-- <X-Plane>/Output/fms-sync/to_sim.txt written by the bridge (our plan)
|
||||
-- <X-Plane>/Output/fms-sync/from_sim.txt written here (the sim's plan)
|
||||
--
|
||||
-- A 3-decimal lat/lon signature de-dupes both sides so they never loop.
|
||||
--
|
||||
-- INSTALL: copy this file to <X-Plane>/Resources/plugins/FlyWithLua/Scripts/
|
||||
-- (install FlyWithLua NG+ first), then restart X-Plane or run
|
||||
-- "FlyWithLua > Reload all Lua script files".
|
||||
-- ============================================================================
|
||||
|
||||
local SYNC = SYSTEM_DIRECTORY .. "Output/fms-sync/"
|
||||
local TO_SIM = SYNC .. "to_sim.txt"
|
||||
local FROM_SIM = SYNC .. "from_sim.txt"
|
||||
local last_sig = nil
|
||||
|
||||
-- make sure the folder exists (bridge also creates it)
|
||||
os.execute('mkdir -p "' .. SYNC .. '" 2>/dev/null || mkdir "' .. SYNC .. '" 2>nul')
|
||||
|
||||
-- 3-decimal lat/lon signature of a waypoint list ---------------------------
|
||||
local function sig_of(wps)
|
||||
local parts = {}
|
||||
for i = 1, #wps do
|
||||
parts[i] = string.format("%.3f,%.3f", wps[i].lat, wps[i].lon)
|
||||
end
|
||||
return table.concat(parts, ";")
|
||||
end
|
||||
|
||||
local function read_file(p)
|
||||
local f = io.open(p, "r"); if not f then return nil end
|
||||
local s = f:read("*a"); f:close(); return s
|
||||
end
|
||||
|
||||
local function write_file(p, s)
|
||||
local f = io.open(p, "w"); if not f then return end
|
||||
f:write(s); f:close()
|
||||
end
|
||||
|
||||
-- parse the bridge file: skip "# sig" lines, take "lat lon alt id type" ------
|
||||
local function parse(txt)
|
||||
local wps = {}
|
||||
if not txt then return wps end
|
||||
for line in txt:gmatch("[^\r\n]+") do
|
||||
if line:sub(1, 1) ~= "#" then
|
||||
local lat, lon, alt, id = line:match("^%s*(-?%d+%.?%d*)%s+(-?%d+%.?%d*)%s+(-?%d+)%s+(%S+)")
|
||||
if lat and lon then
|
||||
wps[#wps + 1] = { lat = tonumber(lat), lon = tonumber(lon), alt = tonumber(alt) or 0, id = id or "WPT" }
|
||||
end
|
||||
end
|
||||
end
|
||||
return wps
|
||||
end
|
||||
|
||||
-- read the current in-sim FMS plan ------------------------------------------
|
||||
local function read_fms()
|
||||
local wps = {}
|
||||
local n = XPLMCountFMSEntries()
|
||||
for i = 0, n - 1 do
|
||||
-- FlyWithLua: type, id, ref, altitude, lat, lon
|
||||
local _t, id, _ref, alt, lat, lon = XPLMGetFMSEntryInfo(i)
|
||||
if lat and lon and (math.abs(lat) > 0.0001 or math.abs(lon) > 0.0001) then
|
||||
wps[#wps + 1] = { lat = lat, lon = lon, alt = alt or 0, id = (id ~= "" and id) or "WPT" }
|
||||
end
|
||||
end
|
||||
return wps
|
||||
end
|
||||
|
||||
-- write our plan into the in-sim FMS ----------------------------------------
|
||||
local function apply_to_fms(wps)
|
||||
local old = XPLMCountFMSEntries()
|
||||
for i = 1, #wps do
|
||||
-- lat/lon entries keep our exact coords -> stable round-trip (no drift)
|
||||
XPLMSetFMSEntryLatLon(i - 1, wps[i].lat, wps[i].lon, math.floor(wps[i].alt or 0))
|
||||
end
|
||||
for i = old - 1, #wps, -1 do XPLMClearFMSEntry(i) end -- trim leftovers
|
||||
if #wps >= 1 then
|
||||
XPLMSetDisplayedFMSEntry(0)
|
||||
XPLMSetDestinationFMSEntry(#wps - 1)
|
||||
end
|
||||
end
|
||||
|
||||
local function serialize(wps)
|
||||
local lines = { "# " .. sig_of(wps) }
|
||||
for i = 1, #wps do
|
||||
lines[#lines + 1] = string.format("%.6f %.6f %d %s WPT", wps[i].lat, wps[i].lon, math.floor(wps[i].alt or 0), wps[i].id)
|
||||
end
|
||||
return table.concat(lines, "\n") .. "\n"
|
||||
end
|
||||
|
||||
-- main loop (~1×/sec): whichever side differs from the agreed plan wins ------
|
||||
function fms_sync_tick()
|
||||
local to_wps = parse(read_file(TO_SIM))
|
||||
local tsig = sig_of(to_wps)
|
||||
local fm_wps = read_fms()
|
||||
local fsig = sig_of(fm_wps)
|
||||
|
||||
if tsig ~= "" and tsig ~= last_sig then
|
||||
apply_to_fms(to_wps) -- App -> Sim
|
||||
last_sig = tsig
|
||||
elseif fsig ~= last_sig then
|
||||
write_file(FROM_SIM, serialize(fm_wps)) -- Sim -> App
|
||||
last_sig = fsig
|
||||
end
|
||||
end
|
||||
|
||||
do_often("fms_sync_tick()")
|
||||
logMsg("[glass-cockpit] FMS sync active -> " .. SYNC)
|
||||
@@ -0,0 +1,55 @@
|
||||
-- ============================================================================
|
||||
-- X-Plane Glass Cockpit — Terrain awareness probe (FlyWithLua companion)
|
||||
-- ============================================================================
|
||||
-- The web MFD can't read X-Plane's scenery elevation over the Web API. This
|
||||
-- script samples a grid of terrain heights around the aircraft with X-Plane's
|
||||
-- terrain probe and writes them to terrain.json in the sync folder; the bridge
|
||||
-- streams it to the tablets, which colour it red/yellow vs aircraft altitude
|
||||
-- (G1000 TAWS). See terrain-sync in server/fmssync.js.
|
||||
--
|
||||
-- INSTALL: copy to <X-Plane>/Resources/plugins/FlyWithLua/Scripts/ (alongside
|
||||
-- fms-sync.lua). Needs FlyWithLua NG+ (XPLM scenery-probe bindings).
|
||||
-- ============================================================================
|
||||
|
||||
local SYNC = SYSTEM_DIRECTORY .. "Output/fms-sync/"
|
||||
local OUT = SYNC .. "terrain.json"
|
||||
os.execute('mkdir -p "' .. SYNC .. '" 2>/dev/null || mkdir "' .. SYNC .. '" 2>nul')
|
||||
|
||||
local M_FT = 3.28084
|
||||
local ROWS, COLS = 24, 24
|
||||
local DLAT, DLON = 0.35, 0.5 -- half-box (deg) around the aircraft
|
||||
local probe = XPLMCreateProbe(0) -- xplm_ProbeY
|
||||
|
||||
-- terrain elevation (ft MSL) at a lat/lon, via the vertical scenery probe
|
||||
local function elev_ft(lat, lon)
|
||||
local x, y, z = XPLMWorldToLocal(lat, lon, 0)
|
||||
local res, _px, py = XPLMProbeTerrainXYZ(probe, x, y, z)
|
||||
if res ~= 0 then return 0 end -- 0 = xplm_ProbeHitTerrain
|
||||
local _plat, _plon, palt = XPLMLocalToWorld(x, py, z)
|
||||
return math.max(0, math.floor(palt * M_FT))
|
||||
end
|
||||
|
||||
function gc_terrain_tick()
|
||||
local lat = get("sim/flightmodel/position/latitude")
|
||||
local lon = get("sim/flightmodel/position/longitude")
|
||||
local alt = math.floor(get("sim/flightmodel/position/elevation") * M_FT) -- true MSL
|
||||
local n, s = lat + DLAT, lat - DLAT
|
||||
local w, e = lon - DLON, lon + DLON
|
||||
local cells = {}
|
||||
for r = 0, ROWS - 1 do -- r = 0 → north (top)
|
||||
local glat = n - (r / (ROWS - 1)) * (n - s)
|
||||
for c = 0, COLS - 1 do -- c = 0 → west
|
||||
local glon = w + (c / (COLS - 1)) * (e - w)
|
||||
cells[#cells + 1] = elev_ft(glat, glon)
|
||||
end
|
||||
end
|
||||
local f = io.open(OUT, "w")
|
||||
if not f then return end
|
||||
f:write(string.format(
|
||||
'{"lat":%.5f,"lon":%.5f,"alt":%d,"n":%.5f,"s":%.5f,"w":%.5f,"e":%.5f,"rows":%d,"cols":%d,"elev":[%s]}',
|
||||
lat, lon, alt, n, s, w, e, ROWS, COLS, table.concat(cells, ",")))
|
||||
f:close()
|
||||
end
|
||||
|
||||
do_often("gc_terrain_tick()") -- ~1×/sec
|
||||
logMsg("[glass-cockpit] terrain probe active -> " .. OUT)
|
||||
@@ -0,0 +1,66 @@
|
||||
-- ============================================================================
|
||||
-- X-Plane Glass Cockpit — G1000 UI-state publisher (FlyWithLua companion)
|
||||
-- ============================================================================
|
||||
-- The web G1000 mirrors the in-sim G1000's display state. Most of it already
|
||||
-- flows over the Web API (attitude, radios, AP, CDI source, baro, ...). The few
|
||||
-- bits that are G1000-internal (MFD page, map range, PFD inset) aren't standard
|
||||
-- datarefs, so this script reads them and re-publishes them under our own
|
||||
-- namespace, which the bridge then streams to every tablet:
|
||||
--
|
||||
-- glasscockpit/ui/mfd_page Int 0 = MAP, 1 = FPL, 2 = NRST
|
||||
-- glasscockpit/ui/map_range_nm Float active map range in NM
|
||||
-- glasscockpit/ui/inset Int PFD inset map on/off (0/1)
|
||||
--
|
||||
-- INSTALL: copy to <X-Plane>/Resources/plugins/FlyWithLua/Scripts/ (alongside
|
||||
-- fms-sync.lua). The web app follows these when present and falls back to its
|
||||
-- own local control when they're absent — so it never breaks without the plugin.
|
||||
-- ============================================================================
|
||||
|
||||
-- our published values (the create_dataref callbacks read these) ------------
|
||||
local ui_mfd_page = -1 -- -1 = "unknown" -> web keeps local control
|
||||
local ui_map_range_nm = -1
|
||||
local ui_inset = -1
|
||||
|
||||
create_dataref("glasscockpit/ui/mfd_page", "Int", function() return ui_mfd_page end)
|
||||
create_dataref("glasscockpit/ui/map_range_nm", "Float", function() return ui_map_range_nm end)
|
||||
create_dataref("glasscockpit/ui/inset", "Int", function() return ui_inset end)
|
||||
|
||||
-- safe optional dataref readers (nil if the dataref doesn't exist) ----------
|
||||
local function geti(name) local h = XPLMFindDataRef(name); if h then return XPLMGetDatai(h) end end
|
||||
local function getf(name) local h = XPLMFindDataRef(name); if h then return XPLMGetDataf(h) end end
|
||||
|
||||
-- ============================================================================
|
||||
-- TODO (confirm in YOUR sim): the exact G1000 source datarefs differ per
|
||||
-- aircraft. Run the probe below once, read the X-Plane Log.txt, and plug the
|
||||
-- right names in here. Until then these stay -1 and the web app uses its own
|
||||
-- local page/range/inset (no harm).
|
||||
-- ============================================================================
|
||||
local function read_g1000_state()
|
||||
-- MAP RANGE — many G1000s expose an NM range or an enum index. Try a couple
|
||||
-- of common candidates; map_range may be an enum needing a lookup table.
|
||||
local rng = getf("sim/cockpit2/EFIS/map_range") -- <-- verify name
|
||||
if rng then ui_map_range_nm = rng end
|
||||
|
||||
-- PFD INSET on/off — G1000-internal, name varies:
|
||||
-- local ins = geti("sim/cockpit2/EFIS/inset_map_on") -- <-- verify name
|
||||
-- if ins then ui_inset = ins end
|
||||
|
||||
-- MFD PAGE group — G1000-internal, name varies:
|
||||
-- local pg = geti("sim/cockpit2/EFIS/mfd_page") -- <-- verify name
|
||||
-- if pg then ui_mfd_page = pg end
|
||||
end
|
||||
|
||||
do_often("read_g1000_state()")
|
||||
|
||||
-- ---- one-shot probe: log every dataref whose name contains a keyword -------
|
||||
-- Bind to a key/macro, fire once, then read Log.txt to discover the real names.
|
||||
function gc_probe_g1000()
|
||||
local hits = {}
|
||||
for _, kw in ipairs({ "EFIS", "g1000", "GPS/g1000", "map_range", "inset", "mfd" }) do
|
||||
logMsg("[glass-cockpit] probe keyword: " .. kw .. " (search Log.txt / DataRefEditor)")
|
||||
end
|
||||
logMsg("[glass-cockpit] tip: use the DataRefEditor or DataRefTool plugin and filter for 'EFIS' / 'g1000' to find map-range / inset / page datarefs, then edit ui-sync.lua")
|
||||
end
|
||||
add_macro("Glass Cockpit: probe G1000 datarefs", "gc_probe_g1000()")
|
||||
|
||||
logMsg("[glass-cockpit] UI-state publisher active (mfd_page / map_range_nm / inset)")
|
||||
+60
-10
@@ -11,9 +11,10 @@ import http from 'node:http';
|
||||
import path from 'node:path';
|
||||
import { fileURLToPath } from 'node:url';
|
||||
import { CONFIG, DATAREFS, WRITABLE_DATAREFS, COMMANDS } from './config.js';
|
||||
import { loadNavData, search as navSearch, navStatus, nearest as navNearest, bbox as navBbox, runwaysNear as navRunways } from './navdata.js';
|
||||
import { loadNavData, search as navSearch, navStatus, nearest as navNearest, bbox as navBbox, runwaysNear as navRunways, airwaysBbox as navAirways } from './navdata.js';
|
||||
import { parseProcedures, procedureLegs as procLegs } from './procedures.js';
|
||||
import * as fp from './flightplan.js';
|
||||
import { pushToSim, startFmsSync, startTerrainSync } from './fmssync.js';
|
||||
|
||||
const __dirname = path.dirname(fileURLToPath(import.meta.url));
|
||||
// WEB_DIST can be overridden (e.g. the desktop app points it at the cockpit
|
||||
@@ -46,7 +47,9 @@ function broadcast(obj) {
|
||||
}
|
||||
|
||||
function broadcastPlan() {
|
||||
broadcast({ type: 'flightplan', data: fp.getPlan() });
|
||||
const plan = fp.getPlan();
|
||||
broadcast({ type: 'flightplan', data: plan });
|
||||
pushToSim(plan); // hand the plan to the FlyWithLua FMS bridge (App → Sim)
|
||||
}
|
||||
|
||||
async function fetchAllByName(resource, names) {
|
||||
@@ -162,6 +165,11 @@ function handleClientMessage(msg) {
|
||||
}
|
||||
if (msg.type === 'fp_remove') { fp.removeWaypoint(msg.index); return broadcastPlan(); }
|
||||
if (msg.type === 'fp_active') { fp.setActiveLeg(msg.index); return broadcastPlan(); }
|
||||
if (msg.type === 'fp_load') {
|
||||
const r = fp.loadFms(msg.name);
|
||||
if (r.ok) return broadcastPlan();
|
||||
return broadcast({ type: 'fp_export_result', ...r });
|
||||
}
|
||||
if (msg.type === 'fp_clear') { fp.setPlan({ waypoints: [] }); return broadcastPlan(); }
|
||||
if (msg.type === 'fp_export') {
|
||||
const r = fp.exportFms(msg.name || 'WEBFPL');
|
||||
@@ -216,6 +224,10 @@ app.get('/api/nav/bbox', (req, res) =>
|
||||
res.json(navBbox(+req.query.s, +req.query.w, +req.query.n, +req.query.e,
|
||||
(req.query.types || 'apt,vor,ndb').split(','), +req.query.limit || 800))
|
||||
);
|
||||
// Airways (Victor/Jet routes) inside a map window — for the MFD AIRWAYS overlay.
|
||||
app.get('/api/nav/airways', (req, res) =>
|
||||
res.json(navAirways(+req.query.s, +req.query.w, +req.query.n, +req.query.e, +req.query.limit || 500))
|
||||
);
|
||||
// Runways near a point — drawn in the PFD synthetic-vision view.
|
||||
app.get('/api/nav/runways', (req, res) =>
|
||||
res.json(navRunways(+req.query.lat, +req.query.lon, +req.query.radius || 12))
|
||||
@@ -227,6 +239,8 @@ app.get('/api/nav/procs', (req, res) => {
|
||||
if (!p) return res.status(404).json({ error: 'no procedures for ' + req.query.icao });
|
||||
res.json({ icao: p.icao, runways: p.runways, sids: p.sids, stars: p.stars, approaches: p.approaches });
|
||||
});
|
||||
// Saved flight plans (Output/FMS plans) — list for the FPL "load" picker.
|
||||
app.get('/api/fms/list', (_req, res) => res.json(fp.listPlans()));
|
||||
app.get('/api/nav/proc', (req, res) =>
|
||||
res.json(procLegs(String(req.query.icao || ''), req.query.type, req.query.name, req.query.trans))
|
||||
);
|
||||
@@ -261,17 +275,23 @@ function startDemo() {
|
||||
heading: 87, slip: 0.3, gForce: 1.04, oat: 9,
|
||||
apState: (1 << 0) | (1 << 1) | (1 << 14), // FD + HDG + ALT
|
||||
apEngaged: 1, apHdgBug: 90, apAltBug: 6000, apVsBug: 500, apSpdBug: 120,
|
||||
// AFCS annunciation: AP on, HDG active + GPS armed (lateral), ALT active (vertical)
|
||||
apMode: 2, hdgStatus: 2, gpssStatus: 1, altStatus: 2,
|
||||
lat: 47.45, lon: -122.31, track: 90, groundspeed: 64, gpsDistNm: 18.4, gpsBearing: 92,
|
||||
// radios (XP freq units: nav/com in 10 kHz, e.g. 11030 = 110.30)
|
||||
nav1: 11030, nav1Sb: 11150, nav2: 11380, nav2Sb: 10890,
|
||||
com1: 12190, com1Sb: 13000, com2: 12475, com2Sb: 12180,
|
||||
// HSI / data fields
|
||||
obsCrs: 175, hsiDef: -0.6, hsiToFrom: 1, navBearing: 168, gsDef: 0.7,
|
||||
nav1Brg: 210, nav1Dme: 12.4, nav2Brg: 320, nav2Dme: 0, // BRG1 (NAV1 VOR/DME) demo
|
||||
|
||||
baro: 29.92, tas: 131, windSpd: 14, windDir: 240,
|
||||
xpdrCode: 1200, xpdrMode: 2, fdPitch: 5, fdRoll: -10,
|
||||
cdiSrc: Number(process.env.DEMO_CDI ?? 2), // 0 VLOC1, 1 VLOC2, 2 GPS
|
||||
...(process.env.DEMO_RANGE ? { uiMapRange: Number(process.env.DEMO_RANGE) } : {}),
|
||||
// engine strip (arrays, like the sim)
|
||||
engRpm: [2410], fuelFlow: [0.0072], oilTemp: [88], oilPress: [52], egt: [720],
|
||||
fuelQty: [60, 58], volts: [28.0], amps: [12],
|
||||
fuelQty: [60, 58], volts: [process.env.DEMO_ALERT ? 23.4 : 28.0], amps: [12],
|
||||
});
|
||||
// a sample plan so the map/FMS show something in demo mode
|
||||
fp.setPlan({ name: 'DEMO', waypoints: [
|
||||
@@ -279,27 +299,57 @@ function startDemo() {
|
||||
{ id: 'SEA', lat: 47.435, lon: -122.310, type: 'VOR', alt: 4000 },
|
||||
{ id: 'KPDX', lat: 45.589, lon: -122.597, type: 'APT', alt: 1200 },
|
||||
]});
|
||||
pushToSim(fp.getPlan());
|
||||
let t = 0;
|
||||
const lat0 = 47.45, lon0 = -122.31, R = 0.05, w = 0.02; // gentle orbit around KSEA
|
||||
const cosL = Math.cos(lat0 * Math.PI / 180);
|
||||
let pLat = lat0, pLon = lon0;
|
||||
setInterval(() => {
|
||||
t += 0.1;
|
||||
state.values.roll = -12 + Math.sin(t) * 4;
|
||||
state.values.pitch = 4.5 + Math.cos(t * 0.7) * 1.5;
|
||||
state.values.heading = (87 + Math.sin(t * 0.3) * 3 + 360) % 360;
|
||||
state.values.track = state.values.heading;
|
||||
state.values.altitude = 5500 + Math.sin(t * 0.5) * 40;
|
||||
state.values.airspeed = 124 + Math.sin(t * 0.4) * 3;
|
||||
// creep south-east so the aircraft visibly moves on the map
|
||||
state.values.lat -= 0.0006;
|
||||
state.values.lon -= 0.0009;
|
||||
const newAlt = 5500 + Math.sin(t * 0.5) * 120;
|
||||
state.values.vspeed = (newAlt - state.values.altitude) / (0.1 / 60); // fpm from Δalt/Δt
|
||||
state.values.altitude = newAlt;
|
||||
state.values.airspeed = 124 + Math.sin(t * 0.4) * 8;
|
||||
// orbit so the aircraft visibly moves but stays near the demo flight plan
|
||||
const lat = lat0 + Math.cos(t * w) * R;
|
||||
const lon = lon0 + Math.sin(t * w) * R / cosL;
|
||||
const trk = (Math.atan2((lon - pLon) * cosL, lat - pLat) * 180 / Math.PI + 360) % 360;
|
||||
state.values.lat = lat; state.values.lon = lon;
|
||||
state.values.track = trk; state.values.heading = trk;
|
||||
pLat = lat; pLon = lon;
|
||||
broadcast({ type: 'status', xpConnected: true });
|
||||
broadcast({ type: 'values', data: state.values });
|
||||
}, 100);
|
||||
// synthetic terrain grid (a Cascades-style ridge rising eastward) so the MFD
|
||||
// terrain-awareness colouring (yellow/red vs aircraft altitude) is visible
|
||||
const emitTerrain = () => {
|
||||
const lat = state.values.lat, lon = state.values.lon, alt = state.values.altitude;
|
||||
const rows = 28, cols = 28, n = lat + 0.35, s = lat - 0.35, w = lon - 0.5, e = lon + 0.5;
|
||||
const elev = [];
|
||||
for (let r = 0; r < rows; r++) for (let c = 0; c < cols; c++) {
|
||||
const fx = c / (cols - 1), fy = r / (rows - 1); // fx: 0 west → 1 east
|
||||
let h = fx * 9000 - 1200 + Math.sin(fy * 6 + fx * 4) * 800 + Math.cos(fx * 9) * 400;
|
||||
elev.push(Math.max(0, Math.round(h)));
|
||||
}
|
||||
broadcast({ type: 'terrain', data: { lat, lon, alt, n, s, w, e, rows, cols, elev } });
|
||||
};
|
||||
emitTerrain();
|
||||
setInterval(emitTerrain, 1500);
|
||||
}
|
||||
|
||||
server.listen(CONFIG.bridgePort, CONFIG.bridgeHost, () => {
|
||||
log(`Bridge UI: http://${CONFIG.bridgeHost}:${CONFIG.bridgePort}`);
|
||||
log(`On tablets: http://<this-PC-LAN-IP>:${CONFIG.bridgePort}`);
|
||||
loadNavData(); // async; FMS resolves idents once ready
|
||||
// FMS two-way sync (Sim → App): adopt plans built/edited in the real G1000
|
||||
startFmsSync({
|
||||
getPlan: () => fp.getPlan(),
|
||||
onSimPlan: (waypoints) => { fp.setPlan({ name: 'ACTIVE', waypoints, activeLeg: 1 }); broadcastPlan(); },
|
||||
});
|
||||
// Terrain awareness grid (from the FlyWithLua terrain probe) → MFD colouring
|
||||
startTerrainSync((t) => broadcast({ type: 'terrain', data: t }));
|
||||
if (process.env.DEMO) startDemo();
|
||||
else connectXPlane();
|
||||
});
|
||||
|
||||
@@ -60,6 +60,25 @@ export const DATAREFS = {
|
||||
hsiToFrom: 'sim/cockpit2/radios/indicators/hsi_flag_from_to_pilot',
|
||||
navBearing: 'sim/cockpit2/radios/indicators/hsi_bearing_deg_mag_pilot',
|
||||
|
||||
// --- bearing pointers (BRG1/BRG2) + DME + marker beacons ---
|
||||
nav1Brg: 'sim/cockpit2/radios/indicators/nav1_bearing_deg_mag',
|
||||
nav2Brg: 'sim/cockpit2/radios/indicators/nav2_bearing_deg_mag',
|
||||
nav1Dme: 'sim/cockpit2/radios/indicators/nav1_dme_distance_nm',
|
||||
nav2Dme: 'sim/cockpit2/radios/indicators/nav2_dme_distance_nm',
|
||||
mkrOuter: 'sim/cockpit2/radios/indicators/outer_marker_lit',
|
||||
mkrMiddle: 'sim/cockpit2/radios/indicators/middle_marker_lit',
|
||||
mkrInner: 'sim/cockpit2/radios/indicators/inner_marker_lit',
|
||||
|
||||
// --- G1000 UI state (for display sync with the in-sim G1000) ---
|
||||
// CDI/HSI source: 0 = NAV1/VLOC1, 1 = NAV2/VLOC2, 2 = GPS (standard dataref).
|
||||
cdiSrc: 'sim/cockpit2/radios/actuators/HSI_source_select_pilot',
|
||||
// The rest are G1000-internal, so the FlyWithLua companion (ui-sync.lua)
|
||||
// publishes them as custom datarefs. Absent until the plugin runs -> the web
|
||||
// G1000 just keeps its own local UI state (graceful).
|
||||
uiMfdPage: 'glasscockpit/ui/mfd_page', // 0 map, 1 fpl, 2 nrst
|
||||
uiMapRange: 'glasscockpit/ui/map_range_nm', // active map range, NM
|
||||
uiInset: 'glasscockpit/ui/inset', // PFD inset map on/off (0/1)
|
||||
|
||||
// --- G1000 PFD: data fields ---
|
||||
baro: 'sim/cockpit2/gauges/actuators/barometer_setting_in_hg_pilot',
|
||||
tas: 'sim/cockpit2/gauges/indicators/true_airspeed_kts_pilot',
|
||||
@@ -88,6 +107,21 @@ export const DATAREFS = {
|
||||
apSpdBug: 'sim/cockpit2/autopilot/airspeed_dial_kts_mach',
|
||||
apEngaged: 'sim/cockpit2/autopilot/servos_on',
|
||||
navHdef: 'sim/cockpit2/radios/indicators/hsi_relative_bearing_vor_pilot',
|
||||
|
||||
// --- AFCS mode annunciation (the green/white mode strip on a real G1000) ---
|
||||
// X-Plane's per-mode status datarefs: 0 = off, 1 = armed, 2 = active/captured.
|
||||
// These mean the AFCS bar mirrors the sim exactly, no Lua needed.
|
||||
apMode: 'sim/cockpit2/autopilot/autopilot_mode', // 0 off, 1 FD, 2 AP
|
||||
hdgStatus: 'sim/cockpit2/autopilot/hdg_status',
|
||||
navStatus: 'sim/cockpit2/autopilot/nav_status',
|
||||
gpssStatus: 'sim/cockpit2/autopilot/gpss_status',
|
||||
aprStatus: 'sim/cockpit2/autopilot/approach_status',
|
||||
bcStatus: 'sim/cockpit2/autopilot/backcourse_status',
|
||||
altStatus: 'sim/cockpit2/autopilot/alt_hold_status',
|
||||
vsStatus: 'sim/cockpit2/autopilot/vvi_status',
|
||||
flcStatus: 'sim/cockpit2/autopilot/speed_status',
|
||||
gsStatus: 'sim/cockpit2/autopilot/glideslope_status',
|
||||
vnavStatus: 'sim/cockpit2/autopilot/vnav_status',
|
||||
};
|
||||
|
||||
// Datarefs the frontend may WRITE (e.g. turning the heading bug knob).
|
||||
@@ -121,6 +155,17 @@ export const COMMANDS = {
|
||||
xpdrIdent: 'sim/transponder/transponder_ident',
|
||||
};
|
||||
|
||||
// Per-radio standby tuning (coarse = MHz, fine = kHz) + active/standby flip.
|
||||
// These work regardless of the dataref's frequency units, so the web tuner just
|
||||
// fires them — no risky raw frequency writes.
|
||||
for (const r of ['nav1', 'nav2', 'com1', 'com2']) {
|
||||
COMMANDS[`${r}CoarseUp`] = `sim/radios/stby_${r}_coarse_up`;
|
||||
COMMANDS[`${r}CoarseDown`] = `sim/radios/stby_${r}_coarse_down`;
|
||||
COMMANDS[`${r}FineUp`] = `sim/radios/stby_${r}_fine_up`;
|
||||
COMMANDS[`${r}FineDown`] = `sim/radios/stby_${r}_fine_down`;
|
||||
COMMANDS[`${r}Swap`] = `sim/radios/${r}_standby_flip`;
|
||||
}
|
||||
|
||||
// Every clickable G1000 bezel control maps to a real X-Plane command. The PFD
|
||||
// is unit n1, the MFD is unit n3 (the default C172 layout). Aliases are
|
||||
// prefixed pfd_/mfd_ so the frontend just says e.g. command('mfd_fpl').
|
||||
|
||||
+40
-3
@@ -87,14 +87,51 @@ export function exportFms(name = 'WEBFPL') {
|
||||
}
|
||||
const content = lines.join('\n') + '\n';
|
||||
|
||||
const root = xplaneRoot();
|
||||
const dir = root ? path.join(root, 'Output', 'FMS plans') : path.join(process.cwd(), 'fms-out');
|
||||
const dir = fmsDir();
|
||||
try {
|
||||
fs.mkdirSync(dir, { recursive: true });
|
||||
const file = path.join(dir, `${name}.fms`);
|
||||
fs.writeFileSync(file, content);
|
||||
return { ok: true, file, intoXplane: !!root };
|
||||
return { ok: true, file, intoXplane: !!xplaneRoot() };
|
||||
} catch (e) {
|
||||
return { ok: false, error: e.message };
|
||||
}
|
||||
}
|
||||
|
||||
// ---- load saved X-Plane .fms plans (Output/FMS plans) ----
|
||||
function fmsDir() {
|
||||
const root = xplaneRoot();
|
||||
return root ? path.join(root, 'Output', 'FMS plans') : path.join(process.cwd(), 'fms-out');
|
||||
}
|
||||
const FMS_TYPE = { 1: 'APT', 2: 'NDB', 3: 'VOR', 11: 'WPT', 28: 'USR' };
|
||||
|
||||
// List the names of every saved .fms plan (X-Plane's own + our exports).
|
||||
export function listPlans() {
|
||||
try {
|
||||
return fs.readdirSync(fmsDir())
|
||||
.filter((f) => f.toLowerCase().endsWith('.fms'))
|
||||
.map((f) => f.replace(/\.fms$/i, ''))
|
||||
.sort((a, b) => a.localeCompare(b));
|
||||
} catch { return []; }
|
||||
}
|
||||
|
||||
// Parse a saved .fms (v1100/v3) into our waypoints and make it the active plan.
|
||||
export function loadFms(name) {
|
||||
const safe = String(name || '').replace(/[^\w .+-]/g, '');
|
||||
const file = path.join(fmsDir(), `${safe}.fms`);
|
||||
if (!fs.existsSync(file)) return { ok: false, error: `not found: ${safe}` };
|
||||
const wps = [];
|
||||
for (const raw of fs.readFileSync(file, 'utf8').split(/\r?\n/)) {
|
||||
const p = raw.trim().split(/\s+/);
|
||||
// waypoint rows start with a numeric type code: <type> <ident> <alt> <lat> <lon>
|
||||
if (p.length >= 5 && /^\d+$/.test(p[0]) && p[0] !== '1100') {
|
||||
const lat = parseFloat(p[3]), lon = parseFloat(p[4]), alt = parseFloat(p[2]);
|
||||
if (isFinite(lat) && isFinite(lon)) {
|
||||
wps.push({ id: p[1], lat, lon, type: FMS_TYPE[+p[0]] || 'WPT', alt: alt > 0 ? Math.round(alt) : null });
|
||||
}
|
||||
}
|
||||
}
|
||||
if (wps.length < 1) return { ok: false, error: 'no waypoints in file' };
|
||||
setPlan({ name: safe.toUpperCase(), waypoints: wps, activeLeg: 1 });
|
||||
return { ok: true, plan, count: wps.length };
|
||||
}
|
||||
|
||||
@@ -0,0 +1,85 @@
|
||||
// Two-way flight-plan sync with X-Plane's in-sim FMS, bridged by a FlyWithLua
|
||||
// companion script (see plugins/fms-sync.lua). X-Plane's Web API can't inject a
|
||||
// flight plan into the FMS, so the Lua script (which has the FMS SDK) does it.
|
||||
//
|
||||
// Channel = two text files in <X-Plane>/Output/fms-sync/ (bridge + Lua run on
|
||||
// the same PC). We write to_sim.txt (our plan); Lua applies it to the FMS and
|
||||
// writes from_sim.txt (the sim's plan); we adopt sim-side changes. A position
|
||||
// signature (3-decimal lat/lon) de-dupes so the two sides never loop.
|
||||
|
||||
import fs from 'node:fs';
|
||||
import path from 'node:path';
|
||||
import { xplaneRoot } from './navdata.js';
|
||||
|
||||
function dir() {
|
||||
const r = xplaneRoot();
|
||||
return r ? path.join(r, 'Output', 'fms-sync') : path.join(process.cwd(), 'fms-sync');
|
||||
}
|
||||
const toSimFile = () => path.join(dir(), 'to_sim.txt');
|
||||
const fromSimFile = () => path.join(dir(), 'from_sim.txt');
|
||||
|
||||
// loop-guard signature: rounded lat/lon list (idents/alt ignored, and coords
|
||||
// from our navdata == X-Plane's, so it stays stable across the round-trip)
|
||||
const sig = (wps) => (wps || []).map((w) => `${(+w.lat).toFixed(3)},${(+w.lon).toFixed(3)}`).join(';');
|
||||
let lastSig = null;
|
||||
|
||||
function serialize(wps) {
|
||||
const body = (wps || [])
|
||||
.map((w) => `${(+w.lat).toFixed(6)} ${(+w.lon).toFixed(6)} ${Math.round(w.alt || 0)} ${w.id || 'WPT'} ${w.type || 'WPT'}`)
|
||||
.join('\n');
|
||||
return `# ${sig(wps)}\n${body}\n`; // first line = sig comment, then waypoints
|
||||
}
|
||||
|
||||
function parse(txt) {
|
||||
const wps = [];
|
||||
for (const ln of (txt || '').split(/\r?\n/)) {
|
||||
const t = ln.trim();
|
||||
if (!t || t.startsWith('#')) continue; // skip sig/comment line
|
||||
const p = t.split(/\s+/);
|
||||
const lat = +p[0], lon = +p[1];
|
||||
if (p.length >= 2 && isFinite(lat) && isFinite(lon) && Math.abs(lat) <= 90 && Math.abs(lon) <= 180) {
|
||||
const alt = +p[2] || 0;
|
||||
wps.push({ id: p[3] || 'WPT', lat, lon, type: p[4] || 'WPT', alt: alt > 0 ? alt : null });
|
||||
}
|
||||
}
|
||||
return wps;
|
||||
}
|
||||
|
||||
// our plan changed → hand it to the Lua script
|
||||
export function pushToSim(plan) {
|
||||
try {
|
||||
fs.mkdirSync(dir(), { recursive: true });
|
||||
fs.writeFileSync(toSimFile(), serialize(plan?.waypoints || []));
|
||||
lastSig = sig(plan?.waypoints || []);
|
||||
} catch { /* sim not local / no write access */ }
|
||||
}
|
||||
|
||||
// Terrain elevation grid published by the FlyWithLua terrain probe
|
||||
// (terrain.json in the sync dir). Polled and broadcast so the MFD can colour
|
||||
// terrain awareness (red/yellow). Only re-broadcasts when it actually changes.
|
||||
const terrainFile = () => path.join(dir(), 'terrain.json');
|
||||
export function startTerrainSync(onTerrain, intervalMs = 1500) {
|
||||
let lastMtime = 0;
|
||||
setInterval(() => {
|
||||
let st;
|
||||
try { st = fs.statSync(terrainFile()); } catch { return; }
|
||||
if (st.mtimeMs === lastMtime) return;
|
||||
lastMtime = st.mtimeMs;
|
||||
try {
|
||||
const t = JSON.parse(fs.readFileSync(terrainFile(), 'utf8'));
|
||||
if (t && Array.isArray(t.elev) && t.elev.length) onTerrain(t);
|
||||
} catch { /* mid-write / malformed */ }
|
||||
}, intervalMs);
|
||||
}
|
||||
|
||||
// poll the Lua-written sim plan; adopt genuine sim-side changes
|
||||
export function startFmsSync({ getPlan, onSimPlan }) {
|
||||
pushToSim(getPlan());
|
||||
setInterval(() => {
|
||||
let txt;
|
||||
try { txt = fs.readFileSync(fromSimFile(), 'utf8'); } catch { return; }
|
||||
const wps = parse(txt);
|
||||
const s = sig(wps);
|
||||
if (wps.length && s && s !== lastSig) { lastSig = s; onSimPlan(wps); }
|
||||
}, 1200);
|
||||
}
|
||||
+81
-7
@@ -41,12 +41,15 @@ const airports = []; // { id, lat, lon, name, elev }
|
||||
const navaids = []; // { id, lat, lon, type:'VOR'|'NDB', freq, name }
|
||||
const fixCells = new Map(); // "ilat,ilon" -> [{ id, lat, lon, type:'FIX' }]
|
||||
const rwyByApt = new Map(); // ICAO -> [{ n1, la1, lo1, n2, la2, lo2, w }] (runway ends + width m)
|
||||
const state = { root: null, loaded: false, count: 0 };
|
||||
const comByApt = new Map(); // ICAO -> { freq, label, prio } (best ATC/CTAF frequency)
|
||||
const ilsApts = new Set(); // ICAOs that have an ILS/LOC approach (for NRST "ILS")
|
||||
const awyCells = new Map(); // "ilat,ilon" (segment midpoint) -> [{ la1, lo1, la2, lo2, name }]
|
||||
const state = { root: null, loaded: false, count: 0, awy: 0 };
|
||||
|
||||
function add(id, lat, lon, type) {
|
||||
function add(id, lat, lon, type, name) {
|
||||
if (!id || !isFinite(lat) || !isFinite(lon)) return;
|
||||
const key = id.toUpperCase();
|
||||
if (!index.has(key)) index.set(key, { id: key, lat, lon, type });
|
||||
if (!index.has(key)) index.set(key, { id: key, lat, lon, type, name: name || '' });
|
||||
}
|
||||
|
||||
function pushFix(f) {
|
||||
@@ -90,10 +93,15 @@ async function parseNav(file) {
|
||||
if (!t || t === '99' || /^[IA]\b/.test(t) || /Version/.test(t)) continue;
|
||||
const p = t.split(/\s+/);
|
||||
const code = parseInt(p[0], 10);
|
||||
if (code === 4 || code === 5) { // ILS/LOC localizer → airport has an ILS
|
||||
const ic = (p[8] || '').toUpperCase();
|
||||
if (ic && ic !== 'ENRT') ilsApts.add(ic);
|
||||
continue;
|
||||
}
|
||||
if (code !== 2 && code !== 3) continue; // 2 = NDB, 3 = VOR/DME
|
||||
const lat = parseFloat(p[1]), lon = parseFloat(p[2]), id = p[7];
|
||||
const type = code === 2 ? 'NDB' : 'VOR';
|
||||
add(id, lat, lon, type);
|
||||
add(id, lat, lon, type, p.slice(10).join(' '));
|
||||
if (id && isFinite(lat) && isFinite(lon)) {
|
||||
// p[4] = frequency (VOR in 10 kHz e.g. 11630 → 116.30; NDB in kHz);
|
||||
// name is everything after the airport/region columns.
|
||||
@@ -110,7 +118,7 @@ async function parseAirports(file) {
|
||||
let icao = null, name = '', elev = 0, placed = false;
|
||||
const place = (lat, lon) => {
|
||||
if (!isFinite(lat) || !isFinite(lon)) return;
|
||||
add(icao, lat, lon, 'APT');
|
||||
add(icao, lat, lon, 'APT', name);
|
||||
airports.push({ id: icao.toUpperCase(), lat, lon, name, elev });
|
||||
placed = true;
|
||||
};
|
||||
@@ -128,10 +136,44 @@ async function parseAirports(file) {
|
||||
}
|
||||
} else if (!placed && icao && (code === 101 || code === 102)) { // water/heli pad
|
||||
place(parseFloat(p[code === 101 ? 4 : 5]), parseFloat(p[code === 101 ? 5 : 6]));
|
||||
} else if (icao && ((code >= 50 && code <= 56) || (code >= 1050 && code <= 1056))) {
|
||||
// ATC / CTAF frequencies. Old codes 50-56, new 1050-1056. Freq is kHz
|
||||
// (>100000) or MHz×100. Keep the most useful one (TWR > UNICOM > ATIS …).
|
||||
const c = code > 1000 ? code - 1000 : code;
|
||||
const raw = parseInt(p[1], 10);
|
||||
if (isFinite(raw) && raw > 0) {
|
||||
const mhz = raw > 100000 ? raw / 1000 : raw / 100;
|
||||
const meta = { 54: ['TOWER', 5], 51: ['UNICOM', 4], 50: ['ATIS', 3], 53: ['GROUND', 2], 55: ['APP', 1], 56: ['DEP', 1], 52: ['CLNC', 1] }[c] || ['COM', 0];
|
||||
const key = icao.toUpperCase(), prev = comByApt.get(key);
|
||||
if (!prev || meta[1] > prev.prio) comByApt.set(key, { freq: mhz, label: meta[0], prio: meta[1] });
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Airways (earth_awy.dat): each row is a segment between two named waypoints.
|
||||
// We resolve both endpoints to coordinates via the fix/navaid index (so this
|
||||
// must run AFTER parseFixes/parseNav) and bucket segments by their midpoint
|
||||
// cell for fast bbox queries — exactly like fixes.
|
||||
async function parseAirways(file) {
|
||||
if (!fs.existsSync(file)) return;
|
||||
const rl = readline.createInterface({ input: fs.createReadStream(file), crlfDelay: Infinity });
|
||||
for await (const line of rl) {
|
||||
const t = line.trim();
|
||||
if (!t || t === '99' || /^[IA]\b/.test(t) || /Version/.test(t)) continue;
|
||||
const p = t.split(/\s+/);
|
||||
if (p.length < 10) continue;
|
||||
const a = index.get((p[0] || '').toUpperCase());
|
||||
const b = index.get((p[3] || '').toUpperCase());
|
||||
if (!a || !b) continue; // endpoint not in our database
|
||||
const name = p[p.length - 1];
|
||||
const k = `${Math.floor((a.lat + b.lat) / 2)},${Math.floor((a.lon + b.lon) / 2)}`;
|
||||
let arr = awyCells.get(k); if (!arr) { arr = []; awyCells.set(k, arr); }
|
||||
arr.push({ la1: a.lat, lo1: a.lon, la2: b.lat, lo2: b.lon, name });
|
||||
state.awy++;
|
||||
}
|
||||
}
|
||||
|
||||
export async function loadNavData() {
|
||||
const root = findRoot();
|
||||
state.root = root;
|
||||
@@ -147,6 +189,10 @@ export async function loadNavData() {
|
||||
try {
|
||||
await parseFixes(pick('earth_fix.dat'));
|
||||
await parseNav(pick('earth_nav.dat'));
|
||||
// airways need the fix/navaid index above; parse in the background.
|
||||
parseAirways(pick('earth_awy.dat'))
|
||||
.then(() => console.log(`navdata: airways done (${state.awy} segments)`))
|
||||
.catch((e) => console.log('navdata: airway parse skipped:', e.message));
|
||||
// apt.dat is large; parse the global airports file in the background.
|
||||
parseAirports(path.join(root, 'Global Scenery', 'Global Airports', 'Earth nav data', 'apt.dat'))
|
||||
.then(() => { state.count = index.size; console.log(`navdata: airports done (${index.size} total entries)`); })
|
||||
@@ -178,13 +224,25 @@ export function search(q, limit = 20) {
|
||||
// NEAREST: closest airports (default) or navaids to a point, with range/bearing.
|
||||
export function nearest(lat, lon, { count = 15, type = 'apt' } = {}) {
|
||||
if (!isFinite(lat) || !isFinite(lon)) return [];
|
||||
const src = (type === 'vor' || type === 'ndb' || type === 'nav') ? navaids : airports;
|
||||
const isApt = !(type === 'vor' || type === 'ndb' || type === 'nav');
|
||||
const src = isApt ? airports : navaids;
|
||||
return src
|
||||
.filter((f) => (type === 'vor' || type === 'ndb') ? f.type.toLowerCase() === type : true)
|
||||
.map((f) => ({ ...f, dist: distNm(lat, lon, f.lat, f.lon), brg: Math.round(bearingDeg(lat, lon, f.lat, f.lon)) }))
|
||||
.sort((a, b) => a.dist - b.dist)
|
||||
.slice(0, count)
|
||||
.map((f) => ({ ...f, dist: +f.dist.toFixed(1) }));
|
||||
.map((f) => {
|
||||
const o = { ...f, dist: +f.dist.toFixed(1) };
|
||||
if (isApt) { // runway length, COM freq, approach type
|
||||
const rs = rwyByApt.get(f.id);
|
||||
let ft = 0;
|
||||
if (rs) for (const r of rs) ft = Math.max(ft, distNm(r.la1, r.lo1, r.la2, r.lo2) * 6076.12);
|
||||
o.rwyFt = Math.round(ft);
|
||||
o.com = comByApt.get(f.id) || null;
|
||||
o.app = ilsApts.has(f.id) ? 'ILS' : 'VFR';
|
||||
}
|
||||
return o;
|
||||
});
|
||||
}
|
||||
|
||||
// BBOX: every feature inside a lat/lon window, for the moving map to draw.
|
||||
@@ -205,6 +263,22 @@ export function bbox(s, w, n, e, types = ['apt', 'vor', 'ndb'], limit = 800) {
|
||||
return out;
|
||||
}
|
||||
|
||||
// BBOX airways: every segment touching a lat/lon window (scan the midpoint
|
||||
// cells overlapping the box, ±1 to catch segments crossing the edge).
|
||||
export function airwaysBbox(s, w, n, e, limit = 500) {
|
||||
const out = [];
|
||||
const inB = (la, lo) => la >= s && la <= n && lo >= w && lo <= e;
|
||||
for (let la = Math.floor(s) - 1; la <= Math.floor(n) + 1; la++)
|
||||
for (let lo = Math.floor(w) - 1; lo <= Math.floor(e) + 1; lo++) {
|
||||
const arr = awyCells.get(`${la},${lo}`);
|
||||
if (!arr) continue;
|
||||
for (const sg of arr) {
|
||||
if (inB(sg.la1, sg.lo1) || inB(sg.la2, sg.lo2)) { out.push(sg); if (out.length >= limit) return out; }
|
||||
}
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
// Runways of every airport within radiusNm — for the PFD's synthetic-vision view.
|
||||
export function runwaysNear(lat, lon, radiusNm = 12) {
|
||||
if (!isFinite(lat) || !isFinite(lon)) return [];
|
||||
|
||||
+14
-2
@@ -1,7 +1,7 @@
|
||||
// Minimal service worker: caches the app shell so the cockpit launches fast and
|
||||
// survives brief network blips. Live data (the bridge WebSocket, /api, and map
|
||||
// tiles) is never cached — only same-origin GET app assets.
|
||||
const CACHE = 'g1000-shell-v1';
|
||||
const CACHE = 'g1000-shell-v2';
|
||||
|
||||
self.addEventListener('install', () => self.skipWaiting());
|
||||
|
||||
@@ -18,7 +18,19 @@ self.addEventListener('fetch', (e) => {
|
||||
if (e.request.method !== 'GET' || url.origin !== location.origin) return;
|
||||
if (url.pathname.startsWith('/api') || url.pathname === '/ws') return;
|
||||
|
||||
// Stale-while-revalidate: serve cache fast, refresh in the background.
|
||||
// The HTML entry is NETWORK-FIRST: a reload always gets the latest build (and
|
||||
// thus the latest hashed assets). Falls back to cache only when offline.
|
||||
const isDoc = e.request.mode === 'navigate' || url.pathname === '/' || url.pathname.endsWith('.html');
|
||||
if (isDoc) {
|
||||
e.respondWith(
|
||||
fetch(e.request)
|
||||
.then((res) => { caches.open(CACHE).then((c) => c.put(e.request, res.clone())); return res; })
|
||||
.catch(() => caches.match(e.request).then((c) => c || caches.match('/')))
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
// Hashed assets are immutable → stale-while-revalidate (fast + self-healing).
|
||||
e.respondWith(
|
||||
caches.open(CACHE).then(async (cache) => {
|
||||
const cached = await cache.match(e.request);
|
||||
|
||||
+86
-21
@@ -1,4 +1,4 @@
|
||||
import React, { useState } from 'react';
|
||||
import React, { useState, useEffect } from 'react';
|
||||
import { useXplane } from './api/useXplane.js';
|
||||
import PFD from './components/PFD.jsx';
|
||||
import AutopilotPanel from './components/AutopilotPanel.jsx';
|
||||
@@ -9,6 +9,8 @@ import VFR from './components/VFR.jsx';
|
||||
import Bezel from './components/Bezel.jsx';
|
||||
import DirectTo from './components/DirectTo.jsx';
|
||||
import Proc from './components/Proc.jsx';
|
||||
import FplPage from './components/FplPage.jsx';
|
||||
import AudioPanel from './components/AudioPanel.jsx';
|
||||
|
||||
// Compact line icons for the nav rail (stroke = currentColor).
|
||||
const ICONS = {
|
||||
@@ -18,6 +20,7 @@ const ICONS = {
|
||||
fms: 'M4 6h14M4 11h14M4 16h9',
|
||||
ap: 'M11 4a7 7 0 100 14 7 7 0 000-14zM11 4v3M11 15v3M4 11h3M15 11h3',
|
||||
vfr: 'M11 4a7 7 0 100 14 7 7 0 000-14zM11 11l4.5-3',
|
||||
audio: 'M11 4a6 6 0 00-6 6v5M17 15v-5a6 6 0 00-6-6M4 14h2.5v4.5H4zM15.5 14H18v4.5h-2.5z',
|
||||
};
|
||||
function Icon({ name }) {
|
||||
return (
|
||||
@@ -36,6 +39,7 @@ const TABS = [
|
||||
{ id: 'fms', label: 'FMS' },
|
||||
{ id: 'vfr', label: 'VFR' },
|
||||
{ id: 'ap', label: 'Autopilot' },
|
||||
{ id: 'audio', label: 'Audio' },
|
||||
];
|
||||
|
||||
export default function App() {
|
||||
@@ -45,27 +49,60 @@ export default function App() {
|
||||
const [navWide, setNavWide] = useState(() => localStorage.getItem('navWide') === '1');
|
||||
const go = (id) => { setTab(id); history.replaceState(null, '', `#${id}`); };
|
||||
const toggleNav = () => setNavWide((w) => { localStorage.setItem('navWide', w ? '0' : '1'); return !w; });
|
||||
// Knob interaction: 'arrows' (visible ˄‹›˅, touch-friendly) or 'zones' (click
|
||||
// the knob face). Settable in the settings panel, remembered.
|
||||
const [knobMode, setKnobMode] = useState(() => localStorage.getItem('knobMode') || 'arrows');
|
||||
const [settings, setSettings] = useState(false);
|
||||
const setKnob = (m) => { localStorage.setItem('knobMode', m); setKnobMode(m); };
|
||||
// Synthetic-terrain (3D) vs. classic blue/brown attitude — toggled by the
|
||||
// PFD → SYN TERR softkey, exactly like the real XPLANE 1000.
|
||||
const [svt3d, setSvt3d] = useState(true);
|
||||
const [svt3d, setSvt3d] = useState(false);
|
||||
// The PFD INSET map (bottom-left) is off by default and toggled by its softkey.
|
||||
const [inset, setInset] = useState(false);
|
||||
// INSET map options (base layer + declutter), set from the INSET submenu.
|
||||
const [insetMode, setInsetMode] = useState({ base: 'topo', dcltr: 0 });
|
||||
// The NRST (nearest airports/navaids) window, toggled by the PFD NRST softkey.
|
||||
const [nrst, setNrst] = useState(false);
|
||||
// The TMR/REF (timer / references) window, toggled by the PFD TMR/REF softkey.
|
||||
const [tmr, setTmr] = useState(false);
|
||||
// MFD map mode (base layer), switched via the Map-Opt softkeys.
|
||||
// Like the real G1000, only ONE window is open at a time. A single string
|
||||
// holds the open one (nrst / tmr / dme / alerts / fpl / dto / proc); toggling
|
||||
// the same softkey closes it, opening another replaces it.
|
||||
const [win, setWin] = useState(null);
|
||||
const toggleWin = (id) => setWin((w) => (w === id ? null : id));
|
||||
const nrst = win === 'nrst', tmr = win === 'tmr', dme = win === 'dme', alerts = win === 'alerts';
|
||||
const fpl = win === 'fpl', dto = win === 'dto', proc = win === 'proc';
|
||||
// MFD map mode (base layer + overlays), switched via the Map-Opt softkeys.
|
||||
const [mapMode, setMapMode] = useState({ base: 'topo' });
|
||||
// Direct-To (D→) dialog — opened from the bezel on either GDU.
|
||||
const [dto, setDto] = useState(false);
|
||||
// PROC (procedures: SID/STAR/approach) dialog — opened from the bezel.
|
||||
const [proc, setProc] = useState(false);
|
||||
// Altimeter barometric units (false = inHg, true = hectopascal) — PFD ALT UNIT softkey.
|
||||
const [baroHpa, setBaroHpa] = useState(false);
|
||||
// Barometric minimums (set in TMR/REF) — shown on the PFD altimeter as BARO MIN.
|
||||
const [minimums, setMinimums] = useState({ on: false, ft: 500 });
|
||||
// OBS (omni-bearing select) mode — suspends GPS sequencing, course set by CRS knob.
|
||||
const [obs, setObs] = useState(false);
|
||||
// MFD page group (MAP / FPL / NRST) — selected by the FMS knob, like the real G1000.
|
||||
const MFD_PAGES = ['map', 'fpl', 'nrst'];
|
||||
const [mfdPage, setMfdPage] = useState('map');
|
||||
const cycleMfd = (dir = 1) => setMfdPage((p) => MFD_PAGES[(MFD_PAGES.indexOf(p) + dir + MFD_PAGES.length) % MFD_PAGES.length]);
|
||||
|
||||
// G1000 UI-state sync (Sim → App): follow the in-sim G1000 when the FlyWithLua
|
||||
// companion publishes its state. No-ops until then, so local control still works.
|
||||
const uiInset = xp.values.uiInset, uiPage = xp.values.uiMfdPage;
|
||||
useEffect(() => { if (uiInset === 0 || uiInset === 1) setInset(!!uiInset); }, [uiInset]);
|
||||
useEffect(() => { if (typeof uiPage === 'number' && MFD_PAGES[uiPage]) setMfdPage(MFD_PAGES[uiPage]); }, [uiPage]);
|
||||
const connKind = xp.xpConnected ? 'ok' : xp.connected ? 'warn' : 'bad';
|
||||
const connText = xp.xpConnected ? 'X-PLANE' : xp.connected ? 'NO SIM' : 'OFFLINE';
|
||||
|
||||
// G1000 side-window dialogs — rendered inside the bezel display so they sit in
|
||||
// the display's lower-right (like the real unit), not over the whole app.
|
||||
const dialogs = (
|
||||
<>
|
||||
{dto && <DirectTo xp={xp} onClose={() => setWin(null)} />}
|
||||
{proc && <Proc xp={xp} onClose={() => setWin(null)} />}
|
||||
{fpl && (
|
||||
<div className="gwin-backdrop" onClick={() => setWin(null)}>
|
||||
<div onClick={(e) => e.stopPropagation()}><FplPage xp={xp} onClose={() => setWin(null)} /></div>
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
|
||||
return (
|
||||
<div className={`app ${navWide ? 'nav-wide' : 'nav-narrow'}`}>
|
||||
<aside className="sidebar">
|
||||
@@ -82,6 +119,13 @@ export default function App() {
|
||||
</button>
|
||||
))}
|
||||
</nav>
|
||||
<button className="snav-i sb-gear" onClick={() => setSettings(true)} title="Einstellungen">
|
||||
<svg className="snav-ic" viewBox="0 0 22 22" width="22" height="22" fill="none" stroke="currentColor" strokeWidth="1.6" strokeLinecap="round" strokeLinejoin="round">
|
||||
<circle cx="11" cy="11" r="3.2" />
|
||||
<path d="M11 2.5v2M11 17.5v2M2.5 11h2M17.5 11h2M5 5l1.4 1.4M15.6 15.6L17 17M17 5l-1.4 1.4M6.4 15.6L5 17" />
|
||||
</svg>
|
||||
<span className="snav-lbl">Einstellungen</span>
|
||||
</button>
|
||||
<div className={`sb-conn ${connKind}`} title={connText}>
|
||||
<span className="dot" />
|
||||
<span className="snav-lbl">{connText}</span>
|
||||
@@ -90,26 +134,47 @@ export default function App() {
|
||||
|
||||
<main className="screen">
|
||||
{tab === 'pfd' && (
|
||||
<Bezel variant="pfd" xp={xp} svt3d={svt3d} onToggleSvt={() => setSvt3d((v) => !v)}
|
||||
<Bezel variant="pfd" xp={xp} knobMode={knobMode} svt3d={svt3d} onToggleSvt={() => setSvt3d((v) => !v)}
|
||||
inset={inset} onSetInset={setInset} insetMode={insetMode} onInsetMode={setInsetMode}
|
||||
nrst={nrst} onToggleNrst={() => setNrst((v) => !v)} onDirect={() => setDto(true)}
|
||||
tmr={tmr} onToggleTmr={() => setTmr((v) => !v)} onProc={() => setProc(true)}>
|
||||
<PFD values={xp.values} svt={svt3d} inset={inset} insetMode={insetMode} nrst={nrst} onCloseNrst={() => setNrst(false)}
|
||||
tmr={tmr} onCloseTmr={() => setTmr(false)} flightPlan={xp.flightPlan} fp={xp.fp} />
|
||||
nrst={nrst} onToggleNrst={() => toggleWin('nrst')} onDirect={() => toggleWin('dto')}
|
||||
tmr={tmr} onToggleTmr={() => toggleWin('tmr')} dme={dme} onToggleDme={() => toggleWin('dme')}
|
||||
alerts={alerts} onToggleAlerts={() => toggleWin('alerts')} onProc={() => toggleWin('proc')} onFpl={() => toggleWin('fpl')} onClr={() => setWin(null)}
|
||||
altHpa={baroHpa} onAltUnit={setBaroHpa} obs={obs} onObs={() => setObs((v) => !v)}>
|
||||
<PFD values={xp.values} command={xp.command} connected={xp.xpConnected} svt={svt3d} inset={inset} insetMode={insetMode} nrst={nrst} onCloseNrst={() => setWin(null)}
|
||||
tmr={tmr} onCloseTmr={() => setWin(null)} dme={dme} onCloseDme={() => setWin(null)}
|
||||
alerts={alerts} onCloseAlerts={() => setWin(null)} baroHpa={baroHpa} obs={obs}
|
||||
minimums={minimums} onMinimums={setMinimums} flightPlan={xp.flightPlan} fp={xp.fp} />
|
||||
{dialogs}
|
||||
</Bezel>
|
||||
)}
|
||||
{tab === 'mfd' && (
|
||||
<Bezel variant="mfd" xp={xp} mapMode={mapMode} onMapMode={setMapMode} onDirect={() => setDto(true)} onProc={() => setProc(true)}>
|
||||
<MFD values={xp.values} flightPlan={xp.flightPlan} fp={xp.fp} mapMode={mapMode} />
|
||||
<Bezel variant="mfd" xp={xp} knobMode={knobMode} mapMode={mapMode} onMapMode={setMapMode} onDirect={() => toggleWin('dto')} onProc={() => toggleWin('proc')} onFms={cycleMfd} onFpl={() => setMfdPage('fpl')} onClr={() => setWin(null)}>
|
||||
<MFD values={xp.values} flightPlan={xp.flightPlan} fp={xp.fp} mapMode={mapMode} page={mfdPage} onCycle={cycleMfd} xp={xp} />
|
||||
{dialogs}
|
||||
</Bezel>
|
||||
)}
|
||||
{tab === 'map' && <MapView values={xp.values} flightPlan={xp.flightPlan} fp={xp.fp} />}
|
||||
{tab === 'fms' && <CDU xp={xp} />}
|
||||
{tab === 'vfr' && <VFR values={xp.values} />}
|
||||
{tab === 'vfr' && <VFR xp={xp} />}
|
||||
{tab === 'ap' && <AutopilotPanel xp={xp} />}
|
||||
{tab === 'audio' && <AudioPanel xp={xp} />}
|
||||
</main>
|
||||
{dto && <DirectTo xp={xp} onClose={() => setDto(false)} />}
|
||||
{proc && <Proc xp={xp} onClose={() => setProc(false)} />}
|
||||
{settings && (
|
||||
<div className="dlg-backdrop" onClick={() => setSettings(false)}>
|
||||
<div className="dlg" onClick={(e) => e.stopPropagation()} style={{ minWidth: 360 }}>
|
||||
<div className="dlg-head">EINSTELLUNGEN</div>
|
||||
<div style={{ padding: 14 }}>
|
||||
<div className="set-lbl">Knopf-Bedienung</div>
|
||||
<div className="set-opt">
|
||||
<button className={`fbtn ${knobMode === 'arrows' ? 'add' : ''}`} onClick={() => setKnob('arrows')}>Pfeiltasten ˄‹›˅</button>
|
||||
<button className={`fbtn ${knobMode === 'zones' ? 'add' : ''}`} onClick={() => setKnob('zones')}>Klickzonen am Knopf</button>
|
||||
</div>
|
||||
<div className="set-hint">Pfeiltasten sind touch-freundlich. Klickzonen: oben/unten = grob, links/rechts = fein, Mitte = PUSH.</div>
|
||||
</div>
|
||||
<div className="dlg-actions"><button className="fbtn" onClick={() => setSettings(false)}>Schließen</button></div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -6,6 +6,7 @@ import { useEffect, useRef, useState, useCallback } from 'react';
|
||||
export function useXplane() {
|
||||
const [values, setValues] = useState({});
|
||||
const [flightPlan, setFlightPlan] = useState({ name: 'ACTIVE', waypoints: [] });
|
||||
const [terrain, setTerrain] = useState(null); // elevation grid for terrain awareness
|
||||
const [exportMsg, setExportMsg] = useState(null);
|
||||
const [connected, setConnected] = useState(false); // socket to bridge
|
||||
const [xpConnected, setXpConnected] = useState(false); // bridge <-> X-Plane
|
||||
@@ -37,6 +38,7 @@ export function useXplane() {
|
||||
}
|
||||
else if (msg.type === 'status') setXpConnected(!!msg.xpConnected);
|
||||
else if (msg.type === 'flightplan') setFlightPlan(msg.data);
|
||||
else if (msg.type === 'terrain') setTerrain(msg.data);
|
||||
else if (msg.type === 'fp_export_result') setExportMsg(msg);
|
||||
};
|
||||
ws.onclose = () => {
|
||||
@@ -68,9 +70,16 @@ export function useXplane() {
|
||||
clear: () => send({ type: 'fp_clear' }),
|
||||
set: (plan) => send({ type: 'fp_set', plan }),
|
||||
export: (name) => send({ type: 'fp_export', name }),
|
||||
load: (name) => send({ type: 'fp_load', name }),
|
||||
};
|
||||
|
||||
return { values, flightPlan, exportMsg, connected, xpConnected, command, setDataref, fp };
|
||||
return { values, flightPlan, terrain, exportMsg, connected, xpConnected, command, setDataref, fp };
|
||||
}
|
||||
|
||||
// List saved .fms flight plans (X-Plane's Output/FMS plans) via the bridge.
|
||||
export async function fmsList() {
|
||||
try { const r = await fetch('/api/fms/list'); return r.ok ? r.json() : []; }
|
||||
catch { return []; }
|
||||
}
|
||||
|
||||
// Search X-Plane's nav database (waypoints/VOR/NDB/airports) via the bridge.
|
||||
@@ -86,3 +95,21 @@ export async function navSearch(q) {
|
||||
|
||||
// Convenience: read a numeric value with a fallback.
|
||||
export const num = (v, d = 0) => (typeof v === 'number' && isFinite(v) ? v : d);
|
||||
const v0 = (x) => (Array.isArray(x) ? num(x[0]) : num(x));
|
||||
|
||||
// System alerts/annunciations derived from live datarefs — drives the PFD
|
||||
// CAUTION softkey + the ALERTS window. Each: { t: text, warn: bool (red vs amber) }.
|
||||
export function systemAlerts(V = {}) {
|
||||
const out = [];
|
||||
const rpm = v0(V.engRpm);
|
||||
const running = rpm > 400;
|
||||
const oilP = v0(V.oilPress);
|
||||
const oilT = v0(V.oilTemp); const oilF = oilT > 150 ? oilT : oilT * 9 / 5 + 32;
|
||||
const volts = v0(V.volts);
|
||||
const fuelGal = (Array.isArray(V.fuelQty) ? V.fuelQty.reduce((a, b) => a + num(b), 0) : num(V.fuelQty)) / 2.72;
|
||||
if (running && oilP < 20) out.push({ t: 'OIL PRESSURE', warn: true });
|
||||
if (oilF > 245) out.push({ t: 'OIL TEMP HIGH', warn: true });
|
||||
if (Array.isArray(V.fuelQty) && fuelGal < 5) out.push({ t: 'FUEL LOW TOTAL', warn: fuelGal < 2.5 });
|
||||
if (volts > 1 && volts < 24.5) out.push({ t: 'LOW VOLTS', warn: false });
|
||||
return out;
|
||||
}
|
||||
|
||||
@@ -0,0 +1,93 @@
|
||||
import React, { useState } from 'react';
|
||||
|
||||
// X1000 Audio Panel (Manual S.91). Selects which radios are heard, which COM is
|
||||
// used to transmit (MIC), marker/DME/ADF Morse audio, intercom, and the Display
|
||||
// Backup (reversionary) key. Selections are local state with authentic lit keys.
|
||||
//
|
||||
// COM MIC is single-select (one transmit radio); the receive/audio keys and the
|
||||
// Morse keys toggle independently — exactly like the real unit.
|
||||
export default function AudioPanel({ xp }) {
|
||||
const [mic, setMic] = useState('com1'); // transmit radio: com1 | com2 | tel
|
||||
const [recv, setRecv] = useState({ com1: true }); // receive/audio selections
|
||||
const [hiSens, setHiSens] = useState(false);
|
||||
const [crew, setCrew] = useState('pilot');
|
||||
const [vol, setVol] = useState(60);
|
||||
|
||||
const r = (k) => !!recv[k];
|
||||
const toggle = (k) => setRecv((s) => ({ ...s, [k]: !s[k] }));
|
||||
// a single audio key: lit green for MIC (transmit), cyan for receive/Morse
|
||||
const Key = ({ k, label, sub, on, kind = 'recv', onClick }) => (
|
||||
<button className={`apk ${kind} ${on ? 'on' : ''}`} onClick={onClick}>
|
||||
<span className="apk-l">{label}</span>{sub && <span className="apk-s">{sub}</span>}
|
||||
</button>
|
||||
);
|
||||
|
||||
return (
|
||||
<div className="audio-panel">
|
||||
<div className="apnl">
|
||||
<div className="apnl-title">AUDIO PANEL</div>
|
||||
|
||||
<div className="apnl-grp">
|
||||
<div className="apnl-h">COM</div>
|
||||
<div className="apnl-row">
|
||||
<Key label="COM1 MIC" kind="mic" on={mic === 'com1'} onClick={() => setMic('com1')} />
|
||||
<Key label="COM1" on={r('com1')} onClick={() => toggle('com1')} />
|
||||
</div>
|
||||
<div className="apnl-row">
|
||||
<Key label="COM2 MIC" kind="mic" on={mic === 'com2'} onClick={() => setMic('com2')} />
|
||||
<Key label="COM2" on={r('com2')} onClick={() => toggle('com2')} />
|
||||
</div>
|
||||
<div className="apnl-row">
|
||||
<Key label="COM 1/2" on={false} onClick={() => setMic((m) => (m === 'com1' ? 'com2' : 'com1'))} />
|
||||
<Key label="TEL" kind="mic" on={mic === 'tel'} onClick={() => setMic('tel')} />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="apnl-grp">
|
||||
<div className="apnl-h">CABIN / SPEAKER</div>
|
||||
<div className="apnl-row">
|
||||
<Key label="PA" on={r('pa')} onClick={() => toggle('pa')} />
|
||||
<Key label="SPKR" on={r('spkr')} onClick={() => toggle('spkr')} />
|
||||
</div>
|
||||
<div className="apnl-row">
|
||||
<Key label="MKR / MUTE" on={r('mkr')} onClick={() => toggle('mkr')} />
|
||||
<Key label="HI SENS" on={hiSens} onClick={() => setHiSens((v) => !v)} />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="apnl-grp">
|
||||
<div className="apnl-h">NAV</div>
|
||||
<div className="apnl-row">
|
||||
<Key label="DME" on={r('dme')} onClick={() => toggle('dme')} />
|
||||
<Key label="NAV1" on={r('nav1')} onClick={() => toggle('nav1')} />
|
||||
</div>
|
||||
<div className="apnl-row">
|
||||
<Key label="ADF" on={r('adf')} onClick={() => toggle('adf')} />
|
||||
<Key label="NAV2" on={r('nav2')} onClick={() => toggle('nav2')} />
|
||||
</div>
|
||||
<div className="apnl-row">
|
||||
<Key label="AUX" on={r('aux')} onClick={() => toggle('aux')} />
|
||||
<Key label="MAN SQ" on={r('msq')} onClick={() => toggle('msq')} />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="apnl-grp">
|
||||
<div className="apnl-h">CREW · ICS</div>
|
||||
<div className="apnl-row">
|
||||
<Key label="PILOT" kind="mic" on={crew === 'pilot'} onClick={() => setCrew('pilot')} />
|
||||
<Key label="COPLT" kind="mic" on={crew === 'copilot'} onClick={() => setCrew('copilot')} />
|
||||
</div>
|
||||
<div className="apnl-vol">
|
||||
<span>PILOT INTERCOM VOL</span>
|
||||
<input type="range" min="0" max="100" value={vol} onChange={(e) => setVol(+e.target.value)} />
|
||||
<b>{vol}</b>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<button className="apnl-backup" onClick={() => xp && xp.command && xp.command('mfd_softkey1')} title="Display Backup (reversionary)">
|
||||
DISPLAY BACKUP
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
+101
-51
@@ -1,5 +1,5 @@
|
||||
import React, { useState } from 'react';
|
||||
import { num } from '../api/useXplane.js';
|
||||
import { num, systemAlerts } from '../api/useXplane.js';
|
||||
|
||||
// The physical GDU bezel of X-Plane's "XPLANE 1000" (its G1000 clone):
|
||||
// title bar, knob columns, the 12 softkeys along the bottom — and, on the MFD,
|
||||
@@ -14,7 +14,9 @@ import { num } from '../api/useXplane.js';
|
||||
// SYN TERR toggles the 3D synthetic-vision terrain on/off.
|
||||
const PFD_MENU = {
|
||||
root: ['', 'INSET', '', 'PFD', '', 'CDI', 'DME', 'XPDR', 'IDENT', 'TMR/REF', 'NRST', 'CAUTION'],
|
||||
pfd: ['PATHWAY', 'SYN TERR', 'HRZN HDG', 'APTSIGNS', '', '', '', '', '', '', '', 'BACK'],
|
||||
pfd: ['PATHWAY', 'SYN TERR', 'HRZN HDG', 'APTSIGNS', 'ALT UNIT', '', '', '', '', '', '', 'BACK'],
|
||||
// ALT UNIT submenu: barometric pressure units (inHg / hectopascal), like the manual.
|
||||
altunit: ['IN', 'HPA', '', '', '', '', '', '', '', '', '', 'BACK'],
|
||||
// XPDR submenu: standby/on/alt modes, VFR (1200), CODE entry, IDENT.
|
||||
xpdr: ['STBY', 'ON', 'ALT', 'VFR', '', 'CODE', 'IDENT', '', '', '', '', 'BACK'],
|
||||
// CODE entry turns the softkeys into the octal squawk keypad (digits 0–7).
|
||||
@@ -26,25 +28,29 @@ const PFD_MENU = {
|
||||
// page; TOPO/TERRAIN/OSM switch the base map; BACK returns. (OSM is our tuned
|
||||
// extra layer in an otherwise-empty slot.)
|
||||
const MFD_MENU = {
|
||||
root: ['SYSTEM', 'MAP', '', '', '', '', '', '', '', 'DCLTR', '', ''],
|
||||
root: ['ENGINE', 'MAP', '', '', '', '', '', '', '', 'DCLTR', '', ''],
|
||||
mapopt: ['TRAFFIC', 'PROFILE', 'TOPO', 'TERRAIN', 'AIRWAYS', '', 'NEXRAD', 'OSM', '', '', 'BACK', ''],
|
||||
system: ['DEC FUEL', 'INC FUEL', 'RST FUEL', '', '', '', '', '', '', '', 'BACK', ''],
|
||||
engine: ['DEC FUEL', 'INC FUEL', 'RST FUEL', '', '', '', '', '', '', '', 'BACK', ''],
|
||||
};
|
||||
|
||||
// autopilot_state bitfield (best-effort; tweak per aircraft)
|
||||
const AP_BITS = { fd: 1 << 0, hdg: 1 << 1, vs: 1 << 4, flc: 1 << 6, nav: 1 << 8, apr: 1 << 9, vnav: 1 << 11, altHold: 1 << 14, bc: 1 << 18 };
|
||||
|
||||
export default function Bezel({ variant = 'pfd', xp, svt3d, onToggleSvt, inset, onSetInset, insetMode, onInsetMode, nrst, onToggleNrst, tmr, onToggleTmr, onDirect, onProc, mapMode, onMapMode, children }) {
|
||||
export default function Bezel({ variant = 'pfd', xp, svt3d, onToggleSvt, inset, onSetInset, insetMode, onInsetMode, nrst, onToggleNrst, tmr, onToggleTmr, dme, onToggleDme, alerts, onToggleAlerts, onDirect, onProc, onFpl, onClr, onFms, mapMode, onMapMode, altHpa, onAltUnit, obs, onObs, knobMode = 'arrows', children }) {
|
||||
const u = variant === 'mfd' ? 'mfd' : 'pfd'; // command prefix
|
||||
const fire = (suffix) => xp && xp.command(`${u}_${suffix}`);
|
||||
const [page, setPage] = useState('root'); // softkey menu page
|
||||
const [squawk, setSquawk] = useState(''); // XPDR code being typed
|
||||
|
||||
const menu = variant === 'mfd' ? MFD_MENU : PFD_MENU;
|
||||
const keys = menu[page] || menu.root;
|
||||
let keys = menu[page] || menu.root;
|
||||
// OBS appears in the PFD root only when a flight-plan leg is active (like the real unit)
|
||||
const hasLeg = (xp?.flightPlan?.waypoints?.length || 0) >= 2;
|
||||
if (variant !== 'mfd' && page === 'root' && hasLeg) { keys = keys.slice(); keys[4] = 'OBS'; }
|
||||
const setBase = (b) => onMapMode && onMapMode((m) => ({ ...m, base: m.base === b ? 'dark' : b }));
|
||||
const xpdrMode = num(xp?.values?.xpdrMode);
|
||||
const setMode = (m) => xp && xp.setDataref('xpdrMode', m);
|
||||
const hasAlerts = systemAlerts(xp?.values).length > 0; // lights the CAUTION key
|
||||
|
||||
const typeDigit = (d) => {
|
||||
const next = (squawk + d).slice(-4);
|
||||
@@ -57,28 +63,37 @@ export default function Bezel({ variant = 'pfd', xp, svt3d, onToggleSvt, inset,
|
||||
|
||||
const onSoftkey = (i, label) => {
|
||||
fire(`softkey${i + 1}`); // mirror to the in-sim G1000
|
||||
// declutter cycles through 4 levels (0=all … 3=flight plan only), like the manual
|
||||
const cycleDcltr = (setter) => setter && setter((m) => ({ ...m, dcltr: (((m.dcltr || 0) + 1) % 4) }));
|
||||
if (variant === 'mfd') {
|
||||
if (label === 'MAP') setPage('mapopt');
|
||||
else if (label === 'SYSTEM') setPage('system');
|
||||
else if (label === 'ENGINE') setPage('engine');
|
||||
else if (label === 'BACK') setPage('root');
|
||||
else if (label === 'TOPO') setBase('topo');
|
||||
else if (label === 'TERRAIN') setBase('terrain');
|
||||
else if (label === 'TOPO') setBase('topo'); // relief on/off
|
||||
else if (label === 'TERRAIN') onMapMode && onMapMode((m) => ({ ...m, terrain: !m.terrain })); // awareness overlay (independent)
|
||||
else if (label === 'OSM') setBase('osm');
|
||||
else if (label === 'DCLTR') onMapMode && onMapMode((m) => ({ ...m, dcltr: m.dcltr ? 0 : 1 }));
|
||||
else if (label === 'DCLTR') cycleDcltr(onMapMode);
|
||||
else if (label === 'AIRWAYS') onMapMode && onMapMode((m) => ({ ...m, airways: !m.airways }));
|
||||
} else {
|
||||
if (label === 'PFD') setPage('pfd');
|
||||
else if (label === 'BACK') setPage(page === 'xpdrcode' ? 'xpdr' : 'root');
|
||||
else if (label === 'BACK') setPage({ xpdrcode: 'xpdr', altunit: 'pfd' }[page] || 'root');
|
||||
else if (label === 'SYN TERR') onToggleSvt && onToggleSvt();
|
||||
else if (label === 'ALT UNIT') setPage('altunit');
|
||||
else if (label === 'IN') { onAltUnit && onAltUnit(false); setPage('pfd'); }
|
||||
else if (label === 'HPA') { onAltUnit && onAltUnit(true); setPage('pfd'); }
|
||||
else if (label === 'INSET') {
|
||||
if (page === 'root') { onSetInset && onSetInset(true); setPage('inset'); }
|
||||
else onSetInset && onSetInset(!inset); // toggle from within the submenu
|
||||
}
|
||||
else if (label === 'OFF') { onSetInset && onSetInset(false); setPage('root'); }
|
||||
else if (label === 'DCLTR') onInsetMode && onInsetMode((m) => ({ ...m, dcltr: m.dcltr ? 0 : 1 }));
|
||||
else if (label === 'TOPO') onInsetMode && onInsetMode((m) => ({ ...m, base: 'topo' }));
|
||||
else if (label === 'TERRAIN') onInsetMode && onInsetMode((m) => ({ ...m, base: 'terrain' }));
|
||||
else if (label === 'DCLTR') cycleDcltr(onInsetMode);
|
||||
else if (label === 'TOPO') onInsetMode && onInsetMode((m) => ({ ...m, base: m.base === 'topo' ? 'dark' : 'topo' }));
|
||||
else if (label === 'TERRAIN') onInsetMode && onInsetMode((m) => ({ ...m, terrain: !m.terrain }));
|
||||
else if (label === 'NRST') onToggleNrst && onToggleNrst();
|
||||
else if (label === 'TMR/REF') onToggleTmr && onToggleTmr();
|
||||
else if (label === 'DME') onToggleDme && onToggleDme();
|
||||
else if (label === 'OBS') onObs && onObs(); // suspend / OBS mode (also fires the sim softkey above)
|
||||
else if (label === 'CAUTION') onToggleAlerts && onToggleAlerts();
|
||||
else if (label === 'XPDR') setPage('xpdr');
|
||||
else if (label === 'STBY') setMode(1);
|
||||
else if (label === 'ON') setMode(2);
|
||||
@@ -93,59 +108,71 @@ export default function Bezel({ variant = 'pfd', xp, svt3d, onToggleSvt, inset,
|
||||
// which softkey is "lit" right now
|
||||
const isOn = (label) => {
|
||||
if (variant === 'mfd') return (label === 'TOPO' && mapMode?.base === 'topo')
|
||||
|| (label === 'TERRAIN' && mapMode?.base === 'terrain') || (label === 'OSM' && mapMode?.base === 'osm')
|
||||
|| (label === 'DCLTR' && mapMode?.dcltr > 0);
|
||||
|| (label === 'TERRAIN' && mapMode?.terrain) || (label === 'OSM' && mapMode?.base === 'osm')
|
||||
|| (label === 'DCLTR' && mapMode?.dcltr > 0) || (label === 'AIRWAYS' && mapMode?.airways);
|
||||
return (label === 'SYN TERR' && svt3d) || (label === 'INSET' && inset) || (label === 'NRST' && nrst) || (label === 'TMR/REF' && tmr)
|
||||
|| (label === 'DME' && dme) || (label === 'OBS' && obs) || (label === 'CAUTION' && (alerts || hasAlerts))
|
||||
|| (label === 'STBY' && xpdrMode === 1) || (label === 'ON' && xpdrMode === 2) || (label === 'ALT' && xpdrMode === 3)
|
||||
|| (label === 'IN' && !altHpa) || (label === 'HPA' && altHpa)
|
||||
|| (page === 'inset' && label === 'TOPO' && insetMode?.base === 'topo')
|
||||
|| (page === 'inset' && label === 'TERRAIN' && insetMode?.base === 'terrain')
|
||||
|| (page === 'inset' && label === 'TERRAIN' && insetMode?.terrain)
|
||||
|| (label === 'DCLTR' && insetMode?.dcltr > 0);
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="bezel">
|
||||
<div className="bezel-knobs left">
|
||||
<Knob label="NAV" sub="VOL · PUSH ID" fire={fire}
|
||||
outer={['nav_outer_up', 'nav_outer_down']} inner={['nav_inner_up', 'nav_inner_down']} push="nav12" />
|
||||
<Knob label="HDG" sub="PUSH HDG SYNC" fire={fire}
|
||||
<Knob label="NAV" sub="VOL · PUSH ID" fire={fire} mode={knobMode}
|
||||
outer={['nav_outer_up', 'nav_outer_down']} inner={['nav_inner_up', 'nav_inner_down']} push="nav12"
|
||||
swap={() => xp && xp.command('nav1Swap')} />
|
||||
<Knob label="HDG" sub="PUSH HDG SYNC" fire={fire} mode={knobMode}
|
||||
outer={['hdg_up', 'hdg_down']} push="hdg_sync" />
|
||||
{variant === 'mfd' && xp && <APController xp={xp} />}
|
||||
<Knob label="ALT" sub="" big fire={fire}
|
||||
<Knob label="ALT" sub="" big fire={fire} mode={knobMode}
|
||||
outer={['alt_outer_up', 'alt_outer_down']} inner={['alt_inner_up', 'alt_inner_down']} />
|
||||
</div>
|
||||
|
||||
<div className="bezel-core">
|
||||
<div className="bezel-title">XPLANE 1000</div>
|
||||
<div className="bezel-screen">{children}</div>
|
||||
{page === 'xpdrcode' && (
|
||||
<div className="squawk-entry">SQUAWK <b>{squawk.padEnd(4, '_')}</b></div>
|
||||
)}
|
||||
<div className="bezel-screen">
|
||||
<div className="screen-content">{children}</div>
|
||||
{page === 'xpdrcode' && (
|
||||
<div className="squawk-entry">SQUAWK <b>{squawk.padEnd(4, '_')}</b></div>
|
||||
)}
|
||||
{/* softkey LABELS on the display (lowest line), like the real G1000 */}
|
||||
<div className="sk-labels">
|
||||
{keys.map((s, i) => (
|
||||
<span key={i} className={`skl ${s ? '' : 'empty'} ${s === 'CAUTION' ? 'caution' : ''} ${isOn(s) ? 'on' : ''}`}>{
|
||||
(s === 'DCLTR' && (mapMode?.dcltr || insetMode?.dcltr)) ? `DCLTR-${mapMode?.dcltr || insetMode?.dcltr}` : s
|
||||
}</span>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
{/* physical bezel keys — blank, aligned under the on-screen labels */}
|
||||
<div className="softkeys">
|
||||
{keys.map((s, i) => (
|
||||
<button
|
||||
key={i}
|
||||
disabled={!s}
|
||||
onClick={() => onSoftkey(i, s)}
|
||||
className={`softkey ${s ? '' : 'empty'} ${s === 'CAUTION' ? 'caution' : ''} ${isOn(s) ? 'on' : ''}`}
|
||||
>{s}</button>
|
||||
<button key={i} disabled={!s} onClick={() => onSoftkey(i, s)}
|
||||
className={`softkey ${s ? '' : 'empty'}`} aria-label={s || undefined} />
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="bezel-knobs right">
|
||||
<Knob label="COM" sub="VOL · PUSH SQ" fire={fire}
|
||||
outer={['com_outer_up', 'com_outer_down']} inner={['com_inner_up', 'com_inner_down']} push="com12" />
|
||||
<Knob label="CRS / BARO" sub="PUSH CRS CTR" fire={fire}
|
||||
<Knob label="COM" sub="VOL · PUSH SQ" fire={fire} mode={knobMode}
|
||||
outer={['com_outer_up', 'com_outer_down']} inner={['com_inner_up', 'com_inner_down']} push="com12"
|
||||
swap={() => xp && xp.command('com1Swap')} emerg />
|
||||
<Knob label="CRS / BARO" sub="PUSH CRS CTR" fire={fire} mode={knobMode}
|
||||
outer={['crs_up', 'crs_down']} inner={['baro_up', 'baro_down']} push="crs_sync" />
|
||||
<Knob label="RANGE" sub="PUSH PAN" joy fire={fire}
|
||||
<Knob label="RANGE" sub="PUSH PAN" joy fire={fire} mode={knobMode}
|
||||
outer={['range_up', 'range_down']} push="pan_push" pan />
|
||||
<div className="bezel-grid">
|
||||
<BtnG fire={fire} cmd="direct" onClick={onDirect}>D→</BtnG><BtnG fire={fire} cmd="menu">MENU</BtnG>
|
||||
<BtnG fire={fire} cmd="fpl">FPL</BtnG><BtnG fire={fire} cmd="proc" onClick={onProc}>PROC</BtnG>
|
||||
<BtnG fire={fire} cmd="clr">CLR</BtnG><BtnG fire={fire} cmd="ent">ENT</BtnG>
|
||||
<BtnG fire={fire} mode={knobMode} cmd="direct" onClick={onDirect}>D→</BtnG><BtnG fire={fire} mode={knobMode} cmd="menu">MENU</BtnG>
|
||||
<BtnG fire={fire} mode={knobMode} cmd="fpl" onClick={onFpl}>FPL</BtnG><BtnG fire={fire} mode={knobMode} cmd="proc" onClick={onProc}>PROC</BtnG>
|
||||
<BtnG fire={fire} mode={knobMode} cmd="clr" onClick={onClr}>CLR</BtnG><BtnG fire={fire} mode={knobMode} cmd="ent">ENT</BtnG>
|
||||
</div>
|
||||
<Knob label="FMS" sub="PUSH CRSR" big fire={fire}
|
||||
outer={['fms_outer_up', 'fms_outer_down']} inner={['fms_inner_up', 'fms_inner_down']} push="cursor" />
|
||||
<Knob label="FMS" sub="PUSH CRSR" big fire={fire} mode={knobMode}
|
||||
outer={['fms_outer_up', 'fms_outer_down']} inner={['fms_inner_up', 'fms_inner_down']} push="cursor"
|
||||
onTurn={onFms} />
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
@@ -186,30 +213,53 @@ function APController({ xp }) {
|
||||
// the mouse wheel; the inner ring via the top/bottom arrows (˄ ˅) and shift+wheel.
|
||||
// Clicking the knob centre fires the push action (PUSH …). The RANGE knob also
|
||||
// pans with a directional cross.
|
||||
function Knob({ label, sub, outer, inner, push, big, joy, pan, fire }) {
|
||||
function Knob({ label, sub, outer, inner, push, big, joy, pan, fire, mode = 'arrows', swap, emerg, onTurn }) {
|
||||
// turn the outer ring: fire the sim command AND notify (e.g. cycle MFD page)
|
||||
const outerStep = (dir) => { if (!outer) return; fire(dir > 0 ? outer[0] : outer[1]); if (onTurn) onTurn(dir); };
|
||||
const onWheel = (e) => {
|
||||
if (!outer) return;
|
||||
e.preventDefault();
|
||||
const set = (e.shiftKey && inner) ? inner : outer;
|
||||
fire(e.deltaY < 0 ? set[0] : set[1]);
|
||||
if (e.shiftKey && inner) fire(e.deltaY < 0 ? inner[0] : inner[1]);
|
||||
else outerStep(e.deltaY < 0 ? 1 : -1);
|
||||
};
|
||||
const zoneClick = (e) => {
|
||||
const r = e.currentTarget.getBoundingClientRect();
|
||||
const dx = e.clientX - (r.left + r.width / 2);
|
||||
const dy = e.clientY - (r.top + r.height / 2);
|
||||
const rel = Math.hypot(dx, dy) / (r.width / 2);
|
||||
if (rel < 0.42 && push) { fire(push); return; } // centre → PUSH
|
||||
if (Math.abs(dy) >= Math.abs(dx)) outerStep(dy < 0 ? 1 : -1);
|
||||
else if (inner) fire(dx > 0 ? inner[0] : inner[1]);
|
||||
else outerStep(dx > 0 ? 1 : -1);
|
||||
};
|
||||
const zones = mode === 'zones';
|
||||
return (
|
||||
<div className={`knob-wrap ${big ? 'big' : ''}`}>
|
||||
{swap && <button className="knob-swap" onClick={swap} title="Aktiv ↔ Standby">⇆</button>}
|
||||
{emerg && <span className="knob-emerg">EMERG</span>}
|
||||
<span className="knob-lbl">{label}</span>
|
||||
<div className="knob-cluster">
|
||||
{inner && <button className="knob-arrow top" onClick={() => fire(inner[0])}>˄</button>}
|
||||
{outer && <button className="knob-arrow left" onClick={() => fire(outer[1])}>‹</button>}
|
||||
<div className={`knob-cluster ${zones ? 'zones' : ''}`}>
|
||||
{/* arrows mode (touch-friendly): visible ˄‹›˅ buttons. zones mode: click
|
||||
the knob face itself (top/bottom = outer, left/right = inner). */}
|
||||
{!zones && inner && <button className="knob-arrow top" onClick={() => fire(inner[0])}>˄</button>}
|
||||
{!zones && outer && <button className="knob-arrow left" onClick={() => outerStep(-1)}>‹</button>}
|
||||
<button
|
||||
className={`knob outer ${joy ? 'joy' : ''}`}
|
||||
onWheel={onWheel}
|
||||
onClick={() => push && fire(push)}
|
||||
title={push ? 'PUSH' : ''}
|
||||
onClick={zones ? zoneClick : (() => push && fire(push))}
|
||||
title={zones ? `${outer ? 'oben/unten' : ''}${inner ? ' · links/rechts (fein)' : ''}${push ? ' · Mitte: PUSH' : ''}` : (push ? 'PUSH' : '')}
|
||||
>
|
||||
<span className="knob inner" />
|
||||
{joy && <div className="joy-cross">+</div>}
|
||||
{joy && (<>
|
||||
<span className="rng-ring" />
|
||||
<span className="rng-arc l">↶</span>
|
||||
<span className="rng-arc r">↷</span>
|
||||
<span className="rng-sign m">–</span>
|
||||
<span className="rng-sign p">+</span>
|
||||
</>)}
|
||||
</button>
|
||||
{outer && <button className="knob-arrow right" onClick={() => fire(outer[0])}>›</button>}
|
||||
{inner && <button className="knob-arrow bottom" onClick={() => fire(inner[1])}>˅</button>}
|
||||
{!zones && outer && <button className="knob-arrow right" onClick={() => outerStep(1)}>›</button>}
|
||||
{!zones && inner && <button className="knob-arrow bottom" onClick={() => fire(inner[1])}>˅</button>}
|
||||
</div>
|
||||
{pan && (
|
||||
<div className="pan-pad">
|
||||
|
||||
@@ -49,41 +49,40 @@ export default function DirectTo({ xp, onClose }) {
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="dlg-backdrop" onClick={onClose}>
|
||||
<div className="gwin-backdrop" onClick={onClose}>
|
||||
<div className="dlg dto" onClick={(e) => e.stopPropagation()}>
|
||||
<div className="dlg-head"><span className="dto-arrow">D→</span> DIRECT TO</div>
|
||||
<div className="dlg-head">DIRECT TO</div>
|
||||
<div className="dto-body">
|
||||
<label className="dto-lbl">WAYPOINT</label>
|
||||
{/* ident line (cyan, edited like the FMS knob) + resolved name below */}
|
||||
<input
|
||||
ref={inputRef}
|
||||
className="dto-input"
|
||||
className="dto-ident"
|
||||
value={entry}
|
||||
onChange={(e) => { setEntry(e.target.value.toUpperCase()); setSel(null); }}
|
||||
onKeyDown={(e) => { if (e.key === 'Enter' && sel) activate(); if (e.key === 'Escape') onClose(); }}
|
||||
placeholder="IDENT (z.B. KSEA, SEA, ELN)"
|
||||
placeholder="_ _ _ _"
|
||||
autoCapitalize="characters" autoCorrect="off" spellCheck="false"
|
||||
/>
|
||||
{hits.length > 0 && (
|
||||
<div className="dto-name">{sel ? (sel.name || sel.type) : ' '}</div>
|
||||
{hits.length > 0 && !sel && (
|
||||
<div className="dto-hits">
|
||||
{hits.map((h) => (
|
||||
<button key={h.id + h.lat} className={sel && sel.id === h.id ? 'on' : ''}
|
||||
onClick={() => { setSel(h); setEntry(h.id); setHits([]); }}>
|
||||
<b>{h.id}</b><i>{h.type}</i><span>{h.lat.toFixed(2)}, {h.lon.toFixed(2)}</span>
|
||||
<button key={h.id + h.lat} onClick={() => { setSel(h); setEntry(h.id); setHits([]); }}>
|
||||
<b>{h.id}</b><span>{h.name || h.type}</span>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
{sel && (
|
||||
<div className="dto-sel">
|
||||
<span className="dto-id">{sel.id}</span>
|
||||
<span className="dto-type">{sel.type}</span>
|
||||
{preview && <span className="dto-vec">{String(Math.round(preview.brg)).padStart(3, '0')}° · {preview.dist.toFixed(1)} NM</span>}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<div className="dlg-actions">
|
||||
<button className="fbtn" onClick={onClose}>CANCEL</button>
|
||||
<button className="fbtn add" disabled={!sel} onClick={activate}>ACTIVATE</button>
|
||||
<div className="dto-grid">
|
||||
<b>ALT</b><span>_____FT</span><b>OFFSET</b><span>+0NM</span>
|
||||
<b>BRG</b><span>{preview ? `${String(Math.round(preview.brg)).padStart(3, '0')}°` : '___°'}</span>
|
||||
<b>DIS</b><span>{preview ? `${preview.dist.toFixed(1)}NM` : '__._NM'}</span>
|
||||
<b>CRS</b><span>{preview ? `${String(Math.round(preview.brg)).padStart(3, '0')}°` : '___°'}</span>
|
||||
<span /><span />
|
||||
</div>
|
||||
<div className="dto-foot">
|
||||
<button className="dto-act" disabled={!sel} onClick={activate}>ACTIVATE</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -0,0 +1,168 @@
|
||||
import React, { useState, useEffect } from 'react';
|
||||
import { num, navSearch, fmsList } from '../api/useXplane.js';
|
||||
|
||||
// G1000 ACTIVE FLIGHT PLAN page (MFD page group + PFD window). Shows the shared
|
||||
// plan as WPT / DTK / DIS / CUM / ALT, active leg in magenta. Edit: type an
|
||||
// ident to insert/append (resolved via X-Plane navdata), ✕ deletes, tap a row to
|
||||
// make it the active leg; CLEAR / INVERT / EXPORT(.fms).
|
||||
const R_NM = 3440.065, rad = (d) => d * Math.PI / 180, 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 brng(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;
|
||||
}
|
||||
const fmtHrs = (h) => { const m = Math.round(h * 60); return `${Math.floor(m / 60)}:${String(m % 60).padStart(2, '0')}`; };
|
||||
|
||||
export default function FplPage({ xp, full = false, onClose }) {
|
||||
const { flightPlan, fp, values, exportMsg } = xp;
|
||||
const wps = flightPlan.waypoints || [];
|
||||
const active = Math.max(1, Math.min(wps.length - 1, flightPlan.activeLeg ?? 1));
|
||||
const [entry, setEntry] = useState('');
|
||||
const [hits, setHits] = useState([]);
|
||||
const [sel, setSel] = useState(-1); // selected row (insert cursor)
|
||||
const [plans, setPlans] = useState(null); // saved .fms list (load picker)
|
||||
const openLoad = async () => setPlans(await fmsList());
|
||||
|
||||
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 addAt = async (ident, index) => {
|
||||
const id = (ident || '').trim().toUpperCase();
|
||||
if (!id) return;
|
||||
const hits2 = await navSearch(id);
|
||||
const hit = hits2[0];
|
||||
if (!hit) return;
|
||||
const next = wps.slice();
|
||||
next.splice(index == null ? next.length : index, 0, { id: hit.id, lat: hit.lat, lon: hit.lon, type: hit.type || 'WPT', alt: null });
|
||||
fp.set({ name: 'ACTIVE', waypoints: next, activeLeg: flightPlan.activeLeg ?? 1 });
|
||||
setEntry(''); setHits([]);
|
||||
};
|
||||
const invert = () => {
|
||||
if (wps.length < 2) return;
|
||||
fp.set({ name: 'ACTIVE', waypoints: wps.slice().reverse(), activeLeg: 1 });
|
||||
};
|
||||
|
||||
// rows with leg + cumulative distance
|
||||
let cum = 0;
|
||||
const rows = wps.map((w, i) => {
|
||||
const prev = wps[i - 1];
|
||||
const d = prev ? distNm(prev, w) : 0;
|
||||
cum += d;
|
||||
return { w, i, d, cum, dtk: prev ? Math.round(brng(prev, w)) : null, orig: i === 0 };
|
||||
});
|
||||
const total = cum;
|
||||
const gs = num(values.groundspeed) * 1.94384;
|
||||
const ete = gs > 30 ? total / gs : null;
|
||||
|
||||
// CURRENT VNV PROFILE: descent to the next waypoint with a lower target
|
||||
// altitude (manual S.64/107). VS TGT for a -3° path, VS REQ to make it, V DEV
|
||||
// from the path, time-to-top-of-descent.
|
||||
const alt = num(values.altitude);
|
||||
let vnav = null;
|
||||
if (gs > 40) {
|
||||
let c = 0, pl = num(values.lat), po = num(values.lon);
|
||||
for (let i = Math.max(1, active); i < wps.length; i++) {
|
||||
c += distNm({ lat: pl, lon: po }, wps[i]); pl = wps[i].lat; po = wps[i].lon;
|
||||
const t = num(wps[i].alt);
|
||||
if (t > 0 && t < alt - 50) {
|
||||
const tan = Math.tan((3 * Math.PI) / 180);
|
||||
const vsTgt = -gs * tan * 101.27;
|
||||
const vsReq = c > 0 ? (t - alt) / (c / gs * 60) : 0;
|
||||
const vDev = alt - (t + c * 6076.12 * tan);
|
||||
const todNm = c - (alt - t) / (6076.12 * tan);
|
||||
vnav = { wptId: wps[i].id, tgtAlt: t, vsTgt, vsReq, vDev, fpa: 3.0, todSec: todNm > 0 ? (todNm / gs) * 3600 : 0 };
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
const fmtSec = (s) => { const m = Math.floor(s / 60), ss = Math.round(s % 60); return `${m}:${String(ss).padStart(2, '0')}`; };
|
||||
|
||||
return (
|
||||
<div className={`fpl ${full ? 'full' : 'win'}`}>
|
||||
<div className="fpl-head">
|
||||
<span>{full ? 'ACTIVE FLIGHT PLAN' : 'FLIGHTPLAN'}</span>
|
||||
<span className="fpl-tot">{total.toFixed(0)} NM{ete ? ` · ${fmtHrs(ete)}` : ''}</span>
|
||||
</div>
|
||||
{!full && wps.length > 0 && (
|
||||
<div className="fpl-od">{wps[0].id} / {wps[wps.length - 1].id}</div>
|
||||
)}
|
||||
<div className="fpl-cols"><span>WPT</span><span>DTK</span><span>DIS</span><span>CUM</span><span>ALT</span></div>
|
||||
<div className="fpl-rows">
|
||||
{rows.length === 0 && <div className="fpl-empty">— leer — Ident unten eingeben</div>}
|
||||
{rows.map(({ w, i, d, cum, dtk, orig }) => (
|
||||
<div key={i} className={`fpl-row ${i === active ? 'act' : ''} ${i === sel ? 'sel' : ''}`}
|
||||
onClick={() => { setSel(i); if (i >= 1) fp.setActive(i); }}>
|
||||
<span className="r-wpt"><b className={i === active ? 'cur' : ''}>{w.id}</b><i>{w.type}</i></span>
|
||||
<span className="r-dtk">{dtk == null ? '___' : `${String(dtk).padStart(3, '0')}°`}</span>
|
||||
<span className="r-dis">{orig ? '—' : d.toFixed(1)}</span>
|
||||
<span className="r-cum">{orig ? '—' : cum.toFixed(0)}</span>
|
||||
<span className={`r-alt ${w.alt ? 'dsgn' : ''}`}>{w.alt ? `${w.alt}FT` : '_____'}</span>
|
||||
<button className="r-del" onClick={(e) => { e.stopPropagation(); fp.remove(i); }}>✕</button>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
{full && (
|
||||
<div className="fpl-vnav">
|
||||
<div className="fpl-vnav-h">CURRENT VNV PROFILE</div>
|
||||
{vnav ? (
|
||||
<div className="fpl-vnav-grid">
|
||||
<b>ACTIVE VNV WPT</b><span className="vwpt">{vnav.tgtAlt}<u>FT</u> at {vnav.wptId}</span>
|
||||
<b>VS TGT</b><span>{Math.round(vnav.vsTgt)}<u>FPM</u></span><b>FPA</b><span>{vnav.fpa.toFixed(1)}°</span>
|
||||
<b>VS REQ</b><span>{Math.round(vnav.vsReq)}<u>FPM</u></span><b>TIME TO TOD</b><span>{fmtSec(vnav.todSec)}</span>
|
||||
<b>V DEV</b><span>{vnav.vDev >= 0 ? '+' : ''}{Math.round(vnav.vDev)}<u>FT</u></span>
|
||||
</div>
|
||||
) : <div className="fpl-vnav-none">— no active VNAV profile —</div>}
|
||||
</div>
|
||||
)}
|
||||
<div className="fpl-entry">
|
||||
{hits.length > 0 && (
|
||||
<div className="fpl-hits">
|
||||
{hits.map((h) => (
|
||||
<button key={h.id + h.lat} onClick={() => addAt(h.id, sel >= 0 ? sel : null)}>
|
||||
<b>{h.id}</b><i>{h.type}</i><span>{h.lat.toFixed(2)}, {h.lon.toFixed(2)}</span>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
<div className="fpl-inrow">
|
||||
<input value={entry} onChange={(e) => setEntry(e.target.value.toUpperCase())}
|
||||
onKeyDown={(e) => e.key === 'Enter' && addAt(entry, sel >= 0 ? sel : null)}
|
||||
placeholder={sel >= 0 ? `Einfügen vor #${sel + 1}` : 'IDENT anhängen (z.B. ELN)'}
|
||||
autoCapitalize="characters" autoCorrect="off" spellCheck="false" />
|
||||
<button className="fpl-btn add" onClick={() => addAt(entry, sel >= 0 ? sel : null)}>EINFÜGEN</button>
|
||||
</div>
|
||||
<div className="fpl-actions">
|
||||
<button className="fpl-btn" onClick={openLoad}>LADEN</button>
|
||||
<button className="fpl-btn" onClick={() => { setSel(-1); fp.clear(); }} disabled={!wps.length}>CLEAR</button>
|
||||
<button className="fpl-btn" onClick={invert} disabled={wps.length < 2}>INVERT</button>
|
||||
<button className="fpl-btn" onClick={() => fp.export('WEBFPL')} disabled={wps.length < 2}>EXPORT →.fms</button>
|
||||
</div>
|
||||
{exportMsg && <div className={`fpl-msg ${exportMsg.ok ? 'ok' : 'err'}`}>{exportMsg.ok ? 'Exportiert ✓' : exportMsg.error}</div>}
|
||||
</div>
|
||||
|
||||
{plans && (
|
||||
<div className="fpl-load" onClick={() => setPlans(null)}>
|
||||
<div className="fpl-load-box" onClick={(e) => e.stopPropagation()}>
|
||||
<div className="fpl-load-head"><span>Gespeicherte Flugpläne</span><button onClick={() => setPlans(null)}>✕</button></div>
|
||||
<div className="fpl-load-list">
|
||||
{plans.length === 0 && <div className="fpl-empty">keine .fms in „Output/FMS plans"</div>}
|
||||
{plans.map((n) => (
|
||||
<button key={n} onClick={() => { fp.load(n); setPlans(null); }}>{n}<i>.fms</i></button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,68 @@
|
||||
import React from 'react';
|
||||
import { num } from '../api/useXplane.js';
|
||||
|
||||
// Bendix/King KAP 140 — the panel-mounted autopilot in the steam-gauge Cessna
|
||||
// 172. A green segment LCD annunciates the active modes + armed altitude, with a
|
||||
// row of buttons (AP HDG NAV APR REV ALT, UP/DN, BARO) and the ALT knob. Buttons
|
||||
// fire X-Plane's own autopilot commands; annunciation comes from autopilot_state.
|
||||
const BITS = { fd: 1 << 0, hdg: 1 << 1, vs: 1 << 4, flc: 1 << 6, nav: 1 << 8, apr: 1 << 9, alt: 1 << 14, bc: 1 << 18 };
|
||||
const on = (s, b) => (num(s) & b) !== 0;
|
||||
|
||||
export default function KAP140({ xp }) {
|
||||
const { values: V, command, setDataref } = xp;
|
||||
const s = num(V.apState), eng = num(V.apEngaged) > 0;
|
||||
const lat = on(s, BITS.apr) ? 'APR' : on(s, BITS.nav) ? 'NAV' : on(s, BITS.bc) ? 'REV' : on(s, BITS.hdg) ? 'HDG' : 'ROL';
|
||||
const vert = on(s, BITS.alt) ? 'ALT' : on(s, BITS.vs) ? 'VS' : '';
|
||||
const selAlt = Math.round(num(V.apAltBug));
|
||||
const vs = Math.round(num(V.apVsBug));
|
||||
|
||||
const Btn = ({ label, cmd }) => (
|
||||
<button className="kap-btn" onClick={() => command(cmd)}>{label}</button>
|
||||
);
|
||||
// A rotary knob: click the upper half to step up, lower half to step down
|
||||
// (also scroll). No +/- buttons.
|
||||
const turn = (e, fn) => {
|
||||
const r = e.currentTarget.getBoundingClientRect();
|
||||
fn(((e.clientY - r.top) < r.height / 2) ? +1 : -1);
|
||||
};
|
||||
const altStep = (d) => setDataref('apAltBug', selAlt + d * 100);
|
||||
|
||||
return (
|
||||
<div className="kap140">
|
||||
<div className="kap-brand">KAP 140</div>
|
||||
<div className="kap-lcd">
|
||||
<div className="kap-l1">
|
||||
<span className={eng ? 'an on' : 'an'}>AP</span>
|
||||
<span className="an on">{lat}</span>
|
||||
<span className="an on">{vert}</span>
|
||||
</div>
|
||||
<div className="kap-l2">
|
||||
<span className="big">{selAlt}</span><span className="u">FT</span>
|
||||
<span className="vs">{vs >= 0 ? '▲' : '▼'} {Math.abs(vs)}<span className="u">FPM</span></span>
|
||||
</div>
|
||||
</div>
|
||||
<div className="kap-keys">
|
||||
<Btn label="AP" cmd="apToggle" />
|
||||
<Btn label="HDG" cmd="hdg" />
|
||||
<Btn label="NAV" cmd="nav" />
|
||||
<Btn label="APR" cmd="apr" />
|
||||
<Btn label="REV" cmd="backCourse" />
|
||||
<Btn label="ALT" cmd="altHold" />
|
||||
<div className="kap-updn">
|
||||
<button className="kap-btn sm" onClick={() => setDataref('apVsBug', vs + 100)}>UP</button>
|
||||
<button className="kap-btn sm" onClick={() => setDataref('apVsBug', vs - 100)}>DN</button>
|
||||
</div>
|
||||
<button className="kap-btn" title="Baro set">BARO</button>
|
||||
</div>
|
||||
<div className="kap-knob">
|
||||
<div className="kap-dial" title="ALT — oben +100 · unten −100"
|
||||
onClick={(e) => turn(e, altStep)}
|
||||
onWheel={(e) => { e.preventDefault(); altStep(e.deltaY < 0 ? 1 : -1); }}>
|
||||
<span className="kdir up">▲</span>
|
||||
<span className="kdir dn">▼</span>
|
||||
</div>
|
||||
<span className="kap-knoblbl">ALT</span>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
+80
-28
@@ -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,33 +69,47 @@ 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">
|
||||
<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 */}
|
||||
{/* 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.nav1)}</text>
|
||||
<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.nav1Sb)}</text>
|
||||
<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="#fff" fontSize="17" textAnchor="end">{navF(V.nav2)}</text>
|
||||
<text x="174" y="58" fill="#fff" fontSize="17">{navF(V.nav2Sb)}</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="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)}
|
||||
@@ -73,8 +128,12 @@ function EisStrip({ V }) {
|
||||
const rpm = arr(V.engRpm);
|
||||
const ffGph = (arr(V.fuelFlow) * 3600) / KG_PER_GAL;
|
||||
const oilPsi = arr(V.oilPress);
|
||||
const oilF = arr(V.oilTemp) * 9 / 5 + 32;
|
||||
const egtF = arr(V.egt) * 9 / 5 + 32;
|
||||
// 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 volts = arr(V.volts, 0, 28);
|
||||
@@ -187,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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -7,6 +7,20 @@ const PLANE_SVG =
|
||||
'<svg viewBox="0 0 24 24" width="34" height="34"><path fill="#ffd400" stroke="#000" stroke-width="1" ' +
|
||||
'd="M12 2l1.5 6.5L22 13v2l-8.5-2.5L13 21l2 1v1l-3-1-3 1v-1l2-1-.5-8.5L2 15v-2l8.5-4.5z"/></svg>';
|
||||
|
||||
// Compass rose anchored to the ownship (north-up), built once. As a Leaflet
|
||||
// marker it tracks the aircraft on every pan — so it always wraps the plane
|
||||
// instead of drifting with the screen.
|
||||
const ROSE_PX = 360;
|
||||
const ROSE_HTML = (() => {
|
||||
const cx = 180, cy = 180, r = 170; let t = '';
|
||||
for (let d = 0; d < 360; d += 10) {
|
||||
const a = ((d - 90) * Math.PI) / 180, big = d % 30 === 0, r2 = r - (big ? 13 : 7);
|
||||
t += `<line 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" stroke-width="${big ? 2 : 1}"/>`;
|
||||
if (big) { const lbl = d === 0 ? 'N' : d === 90 ? 'E' : d === 180 ? 'S' : d === 270 ? 'W' : d / 10; t += `<text x="${cx + (r - 28) * Math.cos(a)}" y="${cy + (r - 28) * Math.sin(a) + 5}" fill="#fff" font-size="16" text-anchor="middle" font-family="monospace">${lbl}</text>`; }
|
||||
}
|
||||
return `<svg width="${ROSE_PX}" height="${ROSE_PX}" viewBox="0 0 360 360">${t}</svg>`;
|
||||
})();
|
||||
|
||||
// A single nav feature rendered as G1000-style symbology: cyan airport, green
|
||||
// VOR hexagon, brown NDB dot-ring, light fix triangle — with an optional label.
|
||||
function navSymbol(f, label) {
|
||||
@@ -43,15 +57,20 @@ const TILES = {
|
||||
dark: null,
|
||||
};
|
||||
|
||||
export default function MapView({ values, flightPlan, fp, inset = false, hud = true, mapMode, dcltr = 0, onView }) {
|
||||
export default function MapView({ values, flightPlan, fp, inset = false, hud = true, mapMode, dcltr = 0, onView, rangeNm, terrain, rose = false }) {
|
||||
const elRef = useRef(null);
|
||||
const mapRef = useRef(null);
|
||||
const acRef = useRef(null);
|
||||
const roseRef = useRef(null);
|
||||
const routeRef = useRef(null);
|
||||
const wpLayerRef = useRef(null);
|
||||
const navLayerRef = useRef(null);
|
||||
const navAbortRef = useRef(null);
|
||||
const awyLayerRef = useRef(null);
|
||||
const awyOnRef = useRef(false);
|
||||
const refreshAirwaysRef = useRef(null);
|
||||
const baseRef = useRef(null);
|
||||
const terrRef = useRef(null);
|
||||
const [follow, setFollow] = useState(true);
|
||||
const followRef = useRef(true);
|
||||
followRef.current = follow;
|
||||
@@ -61,8 +80,10 @@ export default function MapView({ values, flightPlan, fp, inset = false, hud = t
|
||||
const track = num(values.track);
|
||||
const gs = num(values.groundspeed) * 1.94384; // m/s -> kt
|
||||
const base = mapMode?.base || 'topo';
|
||||
const airways = !!mapMode?.airways;
|
||||
const dcltrRef = useRef(dcltr);
|
||||
dcltrRef.current = dcltr;
|
||||
awyOnRef.current = airways;
|
||||
|
||||
// Swap the base tile layer (and report it via the container's dark class).
|
||||
const applyBase = (map, name) => {
|
||||
@@ -82,24 +103,62 @@ export default function MapView({ values, flightPlan, fp, inset = false, hud = t
|
||||
|
||||
// create map once
|
||||
useEffect(() => {
|
||||
const map = L.map(elRef.current, { zoomControl: !inset, attributionControl: false, dragging: !inset, scrollWheelZoom: !inset })
|
||||
const map = L.map(elRef.current, { zoomControl: !inset, attributionControl: false, dragging: !inset, scrollWheelZoom: !inset, zoomSnap: 0 })
|
||||
.setView([lat, lon], inset ? 10 : 9);
|
||||
applyBase(map, base);
|
||||
|
||||
navLayerRef.current = L.layerGroup().addTo(map); // real airports/navaids/fixes
|
||||
// dedicated pane for the terrain-awareness overlay: above the base tiles
|
||||
// (z 200) but below the route / nav symbols (overlayPane z 400)
|
||||
map.createPane('terrain');
|
||||
map.getPane('terrain').style.zIndex = 250;
|
||||
map.getPane('terrain').style.pointerEvents = 'none';
|
||||
|
||||
awyLayerRef.current = L.layerGroup().addTo(map); // airways (under everything else)
|
||||
navLayerRef.current = L.layerGroup().addTo(map); // real airports/navaids/fixes
|
||||
routeRef.current = L.layerGroup().addTo(map); // flight-plan legs (white + magenta active)
|
||||
wpLayerRef.current = L.layerGroup().addTo(map);
|
||||
|
||||
// AIRWAYS overlay (Victor/Jet routes from X-Plane's earth_awy.dat). Light
|
||||
// blue lines with the airway name at the segment midpoint (labels ≥ z8).
|
||||
const refreshAirways = async () => {
|
||||
const layer = awyLayerRef.current;
|
||||
if (!layer) return;
|
||||
if (!awyOnRef.current) { layer.clearLayers(); return; }
|
||||
const b = map.getBounds();
|
||||
try {
|
||||
const res = await fetch(`/api/nav/airways?s=${b.getSouth()}&w=${b.getWest()}&n=${b.getNorth()}&e=${b.getEast()}&limit=600`);
|
||||
if (!res.ok) return;
|
||||
const segs = await res.json();
|
||||
layer.clearLayers();
|
||||
const labels = map.getZoom() >= 8;
|
||||
const seen = new Set();
|
||||
for (const sg of segs) {
|
||||
L.polyline([[sg.la1, sg.lo1], [sg.la2, sg.lo2]], { color: '#5db4e6', weight: 1.2, opacity: 0.7, interactive: false }).addTo(layer);
|
||||
if (labels && sg.name && !seen.has(sg.name)) {
|
||||
seen.add(sg.name);
|
||||
L.marker([(sg.la1 + sg.la2) / 2, (sg.lo1 + sg.lo2) / 2], {
|
||||
icon: L.divIcon({ className: 'awy-divicon', html: `<span class='awy-lbl'>${sg.name}</span>`, iconSize: [0, 0] }),
|
||||
interactive: false,
|
||||
}).addTo(layer);
|
||||
}
|
||||
}
|
||||
} catch { /* offline */ }
|
||||
};
|
||||
refreshAirwaysRef.current = refreshAirways;
|
||||
|
||||
// Pull X-Plane's own nav data for the current view and draw it as G1000-style
|
||||
// vector symbology (cyan airports, green VOR hexagons, NDB dot-rings, fixes).
|
||||
const refreshNav = async () => {
|
||||
const layer = navLayerRef.current;
|
||||
if (!layer) return;
|
||||
const z = map.getZoom();
|
||||
if (z < 6 || dcltrRef.current > 0) { layer.clearLayers(); return; }
|
||||
const types = z >= 10 ? 'apt,vor,ndb,fix' : z >= 8 ? 'apt,vor,ndb' : 'apt';
|
||||
const dc = dcltrRef.current || 0; // 0 all · 1 drop fixes · 2 drop fixes+NDB · 3 flight-plan only
|
||||
if (z < 6 || dc >= 3) { layer.clearLayers(); return; }
|
||||
let types = z >= 10 ? ['apt', 'vor', 'ndb', 'fix'] : z >= 8 ? ['apt', 'vor', 'ndb'] : ['apt'];
|
||||
if (dc >= 1) types = types.filter((t) => t !== 'fix');
|
||||
if (dc >= 2) types = types.filter((t) => t !== 'ndb');
|
||||
const b = map.getBounds();
|
||||
const url = `/api/nav/bbox?s=${b.getSouth()}&w=${b.getWest()}&n=${b.getNorth()}&e=${b.getEast()}&types=${types}&limit=${z >= 10 ? 500 : 250}`;
|
||||
const url = `/api/nav/bbox?s=${b.getSouth()}&w=${b.getWest()}&n=${b.getNorth()}&e=${b.getEast()}&types=${types.join(',')}&limit=${z >= 10 ? 500 : 250}`;
|
||||
try {
|
||||
navAbortRef.current?.abort();
|
||||
navAbortRef.current = new AbortController();
|
||||
@@ -111,10 +170,17 @@ export default function MapView({ values, flightPlan, fp, inset = false, hud = t
|
||||
for (const f of feats) navSymbol(f, labels).addTo(layer);
|
||||
} catch { /* aborted or offline — leave as is */ }
|
||||
};
|
||||
map.on('moveend', () => { refreshNav(); reportView(map); });
|
||||
map.on('moveend', () => { refreshNav(); refreshAirways(); reportView(map); });
|
||||
map.on('zoomend', () => reportView(map));
|
||||
setTimeout(() => { refreshNav(); reportView(map); }, 300);
|
||||
setTimeout(() => { refreshNav(); refreshAirways(); reportView(map); }, 300);
|
||||
|
||||
// compass rose anchored to the aircraft (north-up) — tracks the ownship
|
||||
if (rose) {
|
||||
roseRef.current = L.marker([lat, lon], {
|
||||
icon: L.divIcon({ className: 'rose-divicon', html: ROSE_HTML, iconSize: [ROSE_PX, ROSE_PX], iconAnchor: [ROSE_PX / 2, ROSE_PX / 2] }),
|
||||
interactive: false, zIndexOffset: 600, pane: 'terrain',
|
||||
}).addTo(map);
|
||||
}
|
||||
const icon = L.divIcon({ className: 'ac-divicon', html: PLANE_SVG, iconSize: [34, 34], iconAnchor: [17, 17] });
|
||||
acRef.current = L.marker([lat, lon], { icon, interactive: false, zIndexOffset: 1000 }).addTo(map);
|
||||
|
||||
@@ -134,6 +200,58 @@ export default function MapView({ values, flightPlan, fp, inset = false, hud = t
|
||||
if (map) applyBase(map, base);
|
||||
}, [base]); // eslint-disable-line
|
||||
|
||||
// redraw airways when the AIRWAYS toggle changes
|
||||
useEffect(() => { refreshAirwaysRef.current && refreshAirwaysRef.current(); }, [airways]); // eslint-disable-line
|
||||
|
||||
// TERRAIN AWARENESS overlay: colour the elevation grid (from the FlyWithLua
|
||||
// terrain probe) relative to aircraft altitude — red within 100 ft below/above,
|
||||
// yellow 100–1000 ft below, transparent otherwise (G1000 TAWS colours). Only
|
||||
// when the TERRAIN base is selected.
|
||||
useEffect(() => {
|
||||
const map = mapRef.current;
|
||||
if (!map) return;
|
||||
const t = terrain;
|
||||
const show = !!mapMode?.terrain && t && Array.isArray(t.elev) && t.elev.length === t.rows * t.cols;
|
||||
if (!show) {
|
||||
if (terrRef.current) { map.removeLayer(terrRef.current); terrRef.current = null; }
|
||||
return;
|
||||
}
|
||||
const cv = document.createElement('canvas');
|
||||
cv.width = t.cols; cv.height = t.rows;
|
||||
const ctx = cv.getContext('2d');
|
||||
const img = ctx.createImageData(t.cols, t.rows);
|
||||
for (let i = 0; i < t.elev.length; i++) {
|
||||
const ev = t.elev[i], diff = ev - t.alt;
|
||||
let R = 0, G = 0, B = 0, A = 0;
|
||||
if (ev > 0) {
|
||||
if (diff > -100) { R = 214; G = 22; B = 22; A = 185; } // red
|
||||
else if (diff > -1000) { R = 216; G = 168; B = 20; A = 150; } // yellow
|
||||
}
|
||||
const p = i * 4; img.data[p] = R; img.data[p + 1] = G; img.data[p + 2] = B; img.data[p + 3] = A;
|
||||
}
|
||||
ctx.putImageData(img, 0, 0);
|
||||
const bounds = [[t.s, t.w], [t.n, t.e]];
|
||||
if (!terrRef.current) {
|
||||
terrRef.current = L.imageOverlay(cv.toDataURL(), bounds, { opacity: 0.6, interactive: false, pane: 'terrain' }).addTo(map);
|
||||
} else {
|
||||
terrRef.current.setBounds(bounds);
|
||||
terrRef.current.setUrl(cv.toDataURL());
|
||||
}
|
||||
}, [terrain, mapMode?.terrain]); // eslint-disable-line
|
||||
|
||||
// G1000 UI sync: follow the in-sim map range (centre→top-edge NM). Inverse of
|
||||
// reportView: solve for the zoom that yields the target range at this latitude
|
||||
// and viewport height. Only when the sim publishes it (rangeNm > 0).
|
||||
useEffect(() => {
|
||||
const map = mapRef.current;
|
||||
if (!map || !(rangeNm > 0)) return;
|
||||
const H = map.getSize().y;
|
||||
if (!H) return;
|
||||
const z = Math.log2((156543.03392 * Math.cos(lat * Math.PI / 180) * (H / 2)) / (rangeNm * 1852));
|
||||
const zc = Math.max(3, Math.min(17, z));
|
||||
if (Math.abs(map.getZoom() - zc) > 0.04) map.setZoom(zc, { animate: true });
|
||||
}, [rangeNm]); // eslint-disable-line
|
||||
|
||||
// declutter: hide nav symbology, or repopulate it, when the level changes
|
||||
useEffect(() => {
|
||||
const map = mapRef.current;
|
||||
@@ -147,6 +265,7 @@ export default function MapView({ values, flightPlan, fp, inset = false, hud = t
|
||||
const ac = acRef.current, map = mapRef.current;
|
||||
if (!ac || !map) return;
|
||||
ac.setLatLng([lat, lon]);
|
||||
roseRef.current?.setLatLng([lat, lon]);
|
||||
const el = ac.getElement()?.querySelector('svg');
|
||||
if (el) el.style.transform = `rotate(${track}deg)`;
|
||||
if (followRef.current) map.panTo([lat, lon], { animate: true, duration: 0.5 });
|
||||
|
||||
@@ -19,7 +19,7 @@ const freqStr = (f, type) => {
|
||||
return type === 'vor' ? (n / 100).toFixed(2) : String(n);
|
||||
};
|
||||
|
||||
export default function Nearest({ values, onClose }) {
|
||||
export default function Nearest({ values, onClose, full = false }) {
|
||||
const [type, setType] = useState('apt');
|
||||
const [rows, setRows] = useState([]);
|
||||
const lastRef = useRef(null);
|
||||
@@ -42,35 +42,43 @@ export default function Nearest({ values, onClose }) {
|
||||
}, [type, Math.round(lat * 50), Math.round(lon * 50)]); // re-key on ~1nm moves
|
||||
|
||||
return (
|
||||
<div className="nrst-window">
|
||||
<div className={`nrst-window ${full ? 'full' : ''}`}>
|
||||
<div className="nrst-head">
|
||||
<span className="nrst-title">NEAREST</span>
|
||||
<span className="nrst-title">NEAREST {type === 'apt' ? 'AIRPORTS' : type === 'vor' ? 'VOR' : 'NDB'}</span>
|
||||
<div className="nrst-tabs">
|
||||
{TABS.map((t) => (
|
||||
<button key={t.id} className={type === t.id ? 'on' : ''} onClick={() => setType(t.id)}>{t.label}</button>
|
||||
))}
|
||||
</div>
|
||||
{onClose && <button className="nrst-x" onClick={onClose}>✕</button>}
|
||||
</div>
|
||||
<div className="nrst-cols">
|
||||
<span className="c-id">{type === 'apt' ? 'IDENT' : 'IDENT'}</span>
|
||||
<span className="c-brg">BRG</span>
|
||||
<span className="c-dis">DIS</span>
|
||||
<span className="c-xtra">{type === 'apt' ? 'ELEV' : 'FREQ'}</span>
|
||||
</div>
|
||||
<div className="nrst-list">
|
||||
{rows.length === 0 && <div className="nrst-empty">— no data —</div>}
|
||||
{rows.map((f, i) => (
|
||||
<div className="nrst-row" key={f.id + i}>
|
||||
<span className="c-id">{f.id}</span>
|
||||
<span className="c-brg">{String(num(f.brg)).padStart(3, '0')}°</span>
|
||||
<span className="c-dis">{num(f.dist).toFixed(1)}<u>nm</u></span>
|
||||
<span className="c-xtra">
|
||||
{type === 'apt' ? `${Math.round(num(f.elev))}ft` : freqStr(f.freq, type)}
|
||||
</span>
|
||||
{f.name && <span className="c-name">{f.name}</span>}
|
||||
</div>
|
||||
))}
|
||||
{type === 'apt'
|
||||
? rows.map((f, i) => (
|
||||
<div className="apt-entry" key={f.id + i}>
|
||||
<div className="apt-l1">
|
||||
<span className={`apt-id ${i === 0 ? 'cur' : ''}`}>{f.id}</span>
|
||||
<span className="apt-brg">{String(num(f.brg)).padStart(3, '0')}°</span>
|
||||
<span className="apt-dis">{num(f.dist).toFixed(1)}<u>NM</u></span>
|
||||
<span className={`apt-app ${f.app === 'ILS' ? 'ils' : ''}`}>{f.app || 'VFR'}</span>
|
||||
</div>
|
||||
<div className="apt-l2">
|
||||
<span className="apt-comlbl">{f.com ? f.com.label : ''}</span>
|
||||
<span className="apt-com">{f.com ? f.com.freq.toFixed(3) : ''}</span>
|
||||
<span className="apt-rwlbl">RNWY</span>
|
||||
<span className="apt-rw">{f.rwyFt ? `${f.rwyFt}FT` : '—'}</span>
|
||||
</div>
|
||||
</div>
|
||||
))
|
||||
: rows.map((f, i) => (
|
||||
<div className="nrst-row" key={f.id + i}>
|
||||
<span className="c-id">{f.id}</span>
|
||||
<span className="c-brg">{String(num(f.brg)).padStart(3, '0')}°</span>
|
||||
<span className="c-dis">{num(f.dist).toFixed(1)}<u>nm</u></span>
|
||||
<span className="c-xtra">{freqStr(f.freq, type)}</span>
|
||||
{f.name && <span className="c-name">{f.name}</span>}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
||||
+459
-87
@@ -1,8 +1,9 @@
|
||||
import React, { useRef, useState, useLayoutEffect, Suspense, lazy } from 'react';
|
||||
import { num } from '../api/useXplane.js';
|
||||
import React, { useRef, useState, useEffect, useLayoutEffect, Suspense, lazy } from 'react';
|
||||
import { num, systemAlerts } from '../api/useXplane.js';
|
||||
import MapView from './MapView.jsx';
|
||||
import Nearest from './Nearest.jsx';
|
||||
import TimerRef from './TimerRef.jsx';
|
||||
import RadioTuner from './RadioTuner.jsx';
|
||||
// Lazy-load the heavy WebGL terrain engine only when the PFD is shown.
|
||||
const SVT = lazy(() => import('./SVT.jsx'));
|
||||
|
||||
@@ -57,6 +58,10 @@ function fmtEte(s) {
|
||||
}
|
||||
// VNAV: nearest downstream waypoint with a lower altitude constraint, and the
|
||||
// vertical speed required to meet it at the current groundspeed.
|
||||
// VNAV descent profile to the next waypoint that has a (lower) target altitude:
|
||||
// target VS for a -3° flight path, required VS to make the restriction, vertical
|
||||
// deviation from that path, and time-to-top-of-descent. (Manual S.64 / S.107.)
|
||||
const VNAV_FPA = 3.0; // default flight-path angle (degrees)
|
||||
function vnavInfo(V, fp) {
|
||||
const wps = fp?.waypoints || [];
|
||||
const ai = Math.max(1, Math.min(wps.length - 1, fp?.activeLeg ?? 1));
|
||||
@@ -70,9 +75,16 @@ function vnavInfo(V, fp) {
|
||||
prevLat = wps[i].lat; prevLon = wps[i].lon;
|
||||
const tgt = num(wps[i].alt);
|
||||
if (tgt > 0 && tgt < alt - 50) {
|
||||
const tan = Math.tan((VNAV_FPA * Math.PI) / 180);
|
||||
const tMin = (cum / gs) * 60;
|
||||
const vsReq = tMin > 0 ? (tgt - alt) / tMin : 0;
|
||||
return { wptId: wps[i].id, tgtAlt: tgt, dist: cum, vsReq };
|
||||
const vsReq = tMin > 0 ? (tgt - alt) / tMin : 0; // fpm to make the fix
|
||||
const vsTgt = -gs * tan * 101.27; // fpm for the FPA at this GS
|
||||
const desiredAltNow = tgt + cum * 6076.12 * tan; // path altitude at present position
|
||||
const vDev = alt - desiredAltNow; // + = above path
|
||||
const descentNm = (alt - tgt) / (6076.12 * tan); // distance the descent itself takes
|
||||
const todNm = cum - descentNm; // distance ahead until TOD
|
||||
const todSec = todNm > 0 ? (todNm / gs) * 3600 : 0;
|
||||
return { wptId: wps[i].id, tgtAlt: tgt, dist: cum, vsReq, vsTgt, vDev, fpa: VNAV_FPA, todSec };
|
||||
}
|
||||
}
|
||||
return null;
|
||||
@@ -87,7 +99,51 @@ const SVT_BOX = { x: 0, y: 74, w: W, h: H - 74 };
|
||||
// The INSET moving map sits in the bottom-left corner (toggled by INSET softkey).
|
||||
const INSET_BOX = { x: 6, y: 556, w: 300, h: 172 };
|
||||
|
||||
export default function PFD({ values: V, svt = true, inset = false, insetMode, nrst = false, onCloseNrst, tmr = false, onCloseTmr, flightPlan, fp }) {
|
||||
// Frame-rate-independent easing of a scalar toward a moving target (alpha from
|
||||
// dt + a time constant). Re-renders the consumer only while it's moving —
|
||||
// setState bails out when the value has settled. Used to glide the speed/alt
|
||||
// tapes and the heading rose, just like the imperative attitude smoothing.
|
||||
function useEased(target, tau = 0.08) {
|
||||
const [v, setV] = useState(target);
|
||||
const cur = useRef(target), tg = useRef(target);
|
||||
tg.current = target;
|
||||
useEffect(() => {
|
||||
let raf, last = 0;
|
||||
const loop = (now) => {
|
||||
const dt = last ? Math.min(0.05, (now - last) / 1000) : 0.016; last = now;
|
||||
const k = 1 - Math.exp(-dt / tau);
|
||||
const next = cur.current + (tg.current - cur.current) * k;
|
||||
cur.current = Math.abs(tg.current - next) < 0.02 ? tg.current : next;
|
||||
setV(cur.current);
|
||||
raf = requestAnimationFrame(loop);
|
||||
};
|
||||
raf = requestAnimationFrame(loop);
|
||||
return () => cancelAnimationFrame(raf);
|
||||
}, [tau]);
|
||||
return v;
|
||||
}
|
||||
// As above but eases along the shortest arc across the 0↔360 wrap (headings).
|
||||
function useEasedAngle(target, tau = 0.08) {
|
||||
const [v, setV] = useState(target);
|
||||
const cur = useRef(target), tg = useRef(target);
|
||||
tg.current = target;
|
||||
useEffect(() => {
|
||||
let raf, last = 0;
|
||||
const loop = (now) => {
|
||||
const dt = last ? Math.min(0.05, (now - last) / 1000) : 0.016; last = now;
|
||||
const k = 1 - Math.exp(-dt / tau);
|
||||
const d = ((tg.current - cur.current + 540) % 360) - 180; // shortest signed arc
|
||||
cur.current = Math.abs(d) < 0.05 ? tg.current : cur.current + d * k;
|
||||
setV(((cur.current % 360) + 360) % 360);
|
||||
raf = requestAnimationFrame(loop);
|
||||
};
|
||||
raf = requestAnimationFrame(loop);
|
||||
return () => cancelAnimationFrame(raf);
|
||||
}, [tau]);
|
||||
return v;
|
||||
}
|
||||
|
||||
export default function PFD({ values: V, command, connected = true, svt = true, inset = false, insetMode, nrst = false, onCloseNrst, tmr = false, onCloseTmr, dme = false, onCloseDme, alerts = false, onCloseAlerts, baroHpa = false, obs = false, minimums, onMinimums, flightPlan, fp }) {
|
||||
const wrapRef = useRef(null);
|
||||
const svgRef = useRef(null);
|
||||
const [box, setBox] = useState(null);
|
||||
@@ -106,6 +162,15 @@ export default function PFD({ values: V, svt = true, inset = false, insetMode, n
|
||||
const map = (b) => ({ left: offX + b.x * scale, top: offY + b.y * scale, width: b.w * scale, height: b.h * scale });
|
||||
setBox(map(SVT_BOX));
|
||||
setInsetBox(map(INSET_BOX));
|
||||
// Window zone (lower-right quadrant): right = altitude tape's right edge
|
||||
// (x≈938), bottom just above the XPDR strip (y≈736), left clear of the HSI
|
||||
// rose (x≈650), top below the baro box (y≈502). Exposed as CSS vars so the
|
||||
// windows sit embedded and never cover the HSI or the baro readout.
|
||||
const root = document.documentElement;
|
||||
root.style.setProperty('--gwin-right', `${Math.max(8, wr.width - (offX + 938 * scale))}px`);
|
||||
root.style.setProperty('--gwin-bottom', `${Math.max(8, wr.height - (offY + 736 * scale))}px`);
|
||||
root.style.setProperty('--gwin-maxw', `${(938 - 650) * scale}px`);
|
||||
root.style.setProperty('--gwin-maxh', `${(736 - 502) * scale}px`);
|
||||
};
|
||||
measure();
|
||||
const ro = new ResizeObserver(measure);
|
||||
@@ -116,6 +181,13 @@ export default function PFD({ values: V, svt = true, inset = false, insetMode, n
|
||||
|
||||
const nav = activeNav(V, flightPlan);
|
||||
const vnav = vnavInfo(V, flightPlan);
|
||||
const [tune, setTune] = useState(null); // radio being tuned (tap a freq)
|
||||
// Eased values so the tapes + heading rose glide between X-Plane's ~20 Hz
|
||||
// samples (VSI a touch softer; attitude is smoothed separately, imperatively).
|
||||
const iasS = useEased(num(V.airspeed));
|
||||
const altS = useEased(num(V.altitude));
|
||||
const vsS = useEased(num(V.vspeed), 0.12);
|
||||
const hdgS = useEasedAngle(((num(V.heading) % 360) + 360) % 360);
|
||||
|
||||
return (
|
||||
<div className="pfd-wrap" ref={wrapRef}>
|
||||
@@ -127,7 +199,7 @@ export default function PFD({ values: V, svt = true, inset = false, insetMode, n
|
||||
{inset && insetBox && (
|
||||
<div className="pfd-inset" style={insetBox}>
|
||||
<MapView values={V} flightPlan={flightPlan} fp={fp} inset
|
||||
mapMode={insetMode} dcltr={insetMode?.dcltr || 0} />
|
||||
mapMode={insetMode} dcltr={insetMode?.dcltr || 0} rangeNm={num(V.uiMapRange) || undefined} />
|
||||
</div>
|
||||
)}
|
||||
<svg ref={svgRef} className="g1000" viewBox={`0 0 ${W} ${H}`} preserveAspectRatio="xMidYMid meet">
|
||||
@@ -140,19 +212,35 @@ export default function PFD({ values: V, svt = true, inset = false, insetMode, n
|
||||
</linearGradient>
|
||||
</defs>
|
||||
{!svt && <rect x="0" y="0" width={W} height={H} fill="#000" />}
|
||||
<RadioBar V={V} />
|
||||
<RadioBar V={V} onTune={command ? setTune : null} />
|
||||
{nav && <NavStatus nav={nav} />}
|
||||
{vnav && <VnavBox vnav={vnav} />}
|
||||
<Attitude V={V} svt={svt} />
|
||||
<AirspeedTape V={V} />
|
||||
<AltitudeTape V={V} />
|
||||
<AFCS V={V} />
|
||||
<Marker V={V} />
|
||||
<AirspeedTape V={V} ias={iasS} />
|
||||
<AltitudeTape V={V} alt={altS} vs={vsS} baroHpa={baroHpa} minimums={minimums} />
|
||||
<GlideSlope V={V} />
|
||||
<HSI V={V} nav={nav} />
|
||||
<HSI V={V} nav={nav} hdg={hdgS} obs={obs} />
|
||||
<HdgCrsBoxes V={V} nav={nav} />
|
||||
<Wind V={V} />
|
||||
<DataStrip V={V} />
|
||||
{/* sensor-failure flags (red X) when X-Plane isn't feeding data — the GDU
|
||||
blanks the affected display and shows a red X, like the real unit */}
|
||||
{!connected && (
|
||||
<g>
|
||||
<RedX x={150} y={113} w={700} h={322} label="AHRS" />
|
||||
<RedX x={60} y={110} w={84} h={350} />
|
||||
<RedX x={W - 154} y={110} w={84} h={350} />
|
||||
<RedX x={W / 2 - 140} y={500} w={280} h={260} label="HDG" />
|
||||
</g>
|
||||
)}
|
||||
</svg>
|
||||
{nrst && <Nearest values={V} onClose={onCloseNrst} />}
|
||||
{tmr && <TimerRef values={V} onClose={onCloseTmr} />}
|
||||
{tmr && <TimerRef values={V} onClose={onCloseTmr} minimums={minimums} onMinimums={onMinimums} />}
|
||||
{dme && <DmeWindow V={V} onClose={onCloseDme} />}
|
||||
{alerts && <AlertsWindow V={V} onClose={onCloseAlerts} />}
|
||||
{tune && <RadioTuner values={V} command={command} radio={tune} onClose={() => setTune(null)} />}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -161,7 +249,7 @@ export default function PFD({ values: V, svt = true, inset = false, insetMode, n
|
||||
// Matches the XPLANE 1000: NAV cyan (active boxed), COM green active /
|
||||
// cyan-boxed standby, a centre flight-plan cell with DIS/BRG, ⇄ swap arrows.
|
||||
const SWAP = '⇔';
|
||||
function RadioBar({ V }) {
|
||||
function RadioBar({ V, onTune }) {
|
||||
const swap = (x, y) => <text x={x} y={y} fill="#0ff" fontSize="17" fontFamily="monospace" textAnchor="middle">{SWAP}</text>;
|
||||
return (
|
||||
<g fontFamily="monospace">
|
||||
@@ -170,16 +258,16 @@ function RadioBar({ V }) {
|
||||
{[330, 560, 690].map((x) => <line key={x} x1={x} y1="2" x2={x} y2="72" stroke="#333" strokeWidth="1.5" />)}
|
||||
<line x1="0" y1="74" x2={W} y2="74" stroke="#3a3a3a" strokeWidth="2" />
|
||||
|
||||
{/* NAV1 / NAV2 (left) */}
|
||||
{/* NAV1 / NAV2 — per manual: standby LEFT (cyan, boxed/tunable), active RIGHT (white) */}
|
||||
<text x="14" y="28" fill="#fff" fontSize="14">NAV1</text>
|
||||
<rect x="58" y="11" width="92" height="22" fill="none" stroke="#0ff" strokeWidth="1.4" />
|
||||
<text x="146" y="28" fill="#0ff" fontSize="19" textAnchor="end">{navF(V.nav1)}</text>
|
||||
<text x="146" y="28" fill="#0ff" fontSize="19" textAnchor="end">{navF(V.nav1Sb)}</text>
|
||||
{swap(176, 28)}
|
||||
<text x="206" y="28" fill="#fff" fontSize="19">{navF(V.nav1Sb)}</text>
|
||||
<text x="206" y="28" fill="#fff" fontSize="19">{navF(V.nav1)}</text>
|
||||
<text x="14" y="60" fill="#fff" fontSize="14">NAV2</text>
|
||||
<text x="146" y="60" fill="#0ff" fontSize="19" textAnchor="end">{navF(V.nav2)}</text>
|
||||
<text x="146" y="60" fill="#0ff" fontSize="19" textAnchor="end">{navF(V.nav2Sb)}</text>
|
||||
{swap(176, 60)}
|
||||
<text x="206" y="60" fill="#fff" fontSize="19">{navF(V.nav2Sb)}</text>
|
||||
<text x="206" y="60" fill="#fff" fontSize="19">{navF(V.nav2)}</text>
|
||||
|
||||
{/* centre: active leg + DIS/BRG */}
|
||||
<text x="430" y="26" fill="#e040fb" fontSize="20" textAnchor="middle">{'→'}</text>
|
||||
@@ -191,15 +279,136 @@ function RadioBar({ V }) {
|
||||
<text x="676" y="60" fill="#fff" fontSize="14">BRG</text>
|
||||
|
||||
{/* COM1 / COM2 (right) */}
|
||||
<text x="720" y="28" fill="#0f0" fontSize="19">{comF(V.com1)}</text>
|
||||
{swap(848, 28)}
|
||||
<rect x="876" y="11" width="100" height="22" fill="none" stroke="#0ff" strokeWidth="1.4" />
|
||||
<text x="970" y="28" fill="#0ff" fontSize="19" textAnchor="end">{comF(V.com1Sb)}</text>
|
||||
<text x={W - 4} y="28" fill="#fff" fontSize="13" textAnchor="end">COM1</text>
|
||||
<text x="720" y="60" fill="#fff" fontSize="19">{comF(V.com2)}</text>
|
||||
{swap(848, 60)}
|
||||
<text x="970" y="60" fill="#fff" fontSize="19" textAnchor="end">{comF(V.com2Sb)}</text>
|
||||
<text x={W - 4} y="60" fill="#fff" fontSize="13" textAnchor="end">COM2</text>
|
||||
<text x="716" y="28" fill="#0f0" fontSize="19">{comF(V.com1)}</text>
|
||||
{swap(844, 28)}
|
||||
<rect x="862" y="12" width="94" height="22" rx="2" fill="none" stroke="#0ff" strokeWidth="1.4" />
|
||||
<text x="950" y="28" fill="#0ff" fontSize="19" textAnchor="end">{comF(V.com1Sb)}</text>
|
||||
<text x={W - 6} y="26" fill="#9aa" fontSize="12" textAnchor="end">COM1</text>
|
||||
<text x="716" y="60" fill="#fff" fontSize="19">{comF(V.com2)}</text>
|
||||
{swap(844, 60)}
|
||||
<text x="950" y="60" fill="#fff" fontSize="19" textAnchor="end">{comF(V.com2Sb)}</text>
|
||||
<text x={W - 6} y="58" fill="#9aa" fontSize="12" textAnchor="end">COM2</text>
|
||||
|
||||
{/* tap a radio to open the touch tuner (big hit areas) */}
|
||||
{onTune && (
|
||||
<g fill="transparent" style={{ cursor: 'pointer' }}>
|
||||
<rect x="6" y="8" width="320" height="26" onClick={() => onTune({ id: 'nav1', label: 'NAV1', isCom: false })} />
|
||||
<rect x="6" y="40" width="320" height="26" onClick={() => onTune({ id: 'nav2', label: 'NAV2', isCom: false })} />
|
||||
<rect x="700" y="8" width="298" height="26" onClick={() => onTune({ id: 'com1', label: 'COM1', isCom: true })} />
|
||||
<rect x="700" y="40" width="298" height="26" onClick={() => onTune({ id: 'com2', label: 'COM2', isCom: true })} />
|
||||
</g>
|
||||
)}
|
||||
</g>
|
||||
);
|
||||
}
|
||||
|
||||
// Red-X failure flag over a blanked instrument region (no valid sensor data).
|
||||
function RedX({ x, y, w, h, label }) {
|
||||
return (
|
||||
<g>
|
||||
<rect x={x} y={y} width={w} height={h} fill="#0a0d10" opacity="0.82" />
|
||||
<line x1={x} y1={y} x2={x + w} y2={y + h} stroke="#e01010" strokeWidth="4" />
|
||||
<line x1={x + w} y1={y} x2={x} y2={y + h} stroke="#e01010" strokeWidth="4" />
|
||||
{label && (
|
||||
<text x={x + w / 2} y={y + h / 2 + 6} textAnchor="middle" fill="#ffce46" fontSize="22" fontWeight="bold" fontFamily="monospace">{label}</text>
|
||||
)}
|
||||
</g>
|
||||
);
|
||||
}
|
||||
|
||||
/* ---------------- DME tuning window (DME softkey) ---------------- */
|
||||
// Mirrors the G1000 DME window: source (NAV1/NAV2/HOLD), frequency, slant
|
||||
// distance, groundspeed and time-to-station — all from the sim's DME datarefs.
|
||||
function DmeWindow({ V, onClose }) {
|
||||
const [src, setSrc] = useState('NAV1');
|
||||
const isN2 = src === 'NAV2';
|
||||
const dis = num(isN2 ? V.nav2Dme : V.nav1Dme);
|
||||
const freq = isN2 ? V.nav2 : V.nav1;
|
||||
const gs = num(V.groundspeed) * 1.94384;
|
||||
const min = dis > 0 && gs > 30 ? (dis / gs) * 60 : null;
|
||||
return (
|
||||
<div className="pfd-pop dme">
|
||||
<div className="nrst-head"><span className="nrst-title">DME</span></div>
|
||||
<div className="pop-grid">
|
||||
<b>MODE</b><span>{src}</span>
|
||||
<b>FREQ</b><span>{navF(freq)}</span>
|
||||
<b>DIS</b><span>{dis > 0 ? `${dis.toFixed(1)} NM` : '– – –'}</span>
|
||||
<b>GS</b><span>{Math.round(gs)} KT</span>
|
||||
<b>TIME</b><span>{min != null ? `${Math.floor(min)}:${String(Math.round((min % 1) * 60)).padStart(2, '0')}` : '– – –'}</span>
|
||||
</div>
|
||||
<div className="pop-tabs">
|
||||
{['NAV1', 'NAV2', 'HOLD'].map((s) => (
|
||||
<button key={s} className={src === s ? 'on' : ''} onClick={() => setSrc(s)}>{s}</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/* ---------------- ALERTS / messages window (CAUTION softkey) ---------------- */
|
||||
function AlertsWindow({ V, onClose }) {
|
||||
const msgs = systemAlerts(V);
|
||||
return (
|
||||
<div className="pfd-pop alerts">
|
||||
<div className="nrst-head"><span className="nrst-title">MESSAGES</span></div>
|
||||
<div className="alerts-list">
|
||||
{msgs.length === 0
|
||||
? <div className="alert-none">NO MESSAGES</div>
|
||||
: msgs.map((m) => <div key={m.t} className={`alert-row ${m.warn ? 'warn' : 'cau'}`}>{m.t}</div>)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/* ---------------- AFCS mode annunciation bar ----------------
|
||||
The green/white mode strip across the top of a real G1000: AP/FD in the
|
||||
centre, the lateral mode pair on the left (active green, armed white) and the
|
||||
vertical mode pair on the right (active + reference, armed). Driven by
|
||||
X-Plane's per-mode _status datarefs (2 = active, 1 = armed), so it mirrors
|
||||
the sim's autopilot exactly. */
|
||||
function AFCS({ V }) {
|
||||
const st = (k) => Math.round(num(V[k]));
|
||||
const apMode = st('apMode');
|
||||
const ap = apMode >= 2 || num(V.apEngaged) > 0;
|
||||
const fd = apMode >= 1 || ap;
|
||||
// resolve an active (green) + armed (white) label from a priority list
|
||||
const pick = (entries) => {
|
||||
let act = '', arm = '';
|
||||
for (const [lbl, s] of entries) {
|
||||
if (s === 2 && !act) act = lbl;
|
||||
else if (s === 1 && !arm) arm = lbl;
|
||||
}
|
||||
return { act, arm };
|
||||
};
|
||||
const lat = pick([
|
||||
['GPS', st('gpssStatus')], ['LOC', st('aprStatus')], ['VOR', st('navStatus')],
|
||||
['BC', st('bcStatus')], ['HDG', st('hdgStatus')],
|
||||
]);
|
||||
const vrt = pick([
|
||||
['GS', st('gsStatus')], ['ALT', st('altStatus')], ['VS', st('vsStatus')],
|
||||
['FLC', st('flcStatus')], ['VNV', st('vnavStatus')],
|
||||
]);
|
||||
if (!lat.act && fd) lat.act = 'ROL'; // wings-level default
|
||||
if (!vrt.act && fd) vrt.act = 'PIT'; // pitch-hold default
|
||||
// reference value beside the active vertical mode
|
||||
const ref = vrt.act === 'ALT' ? `${Math.round(num(V.apAltBug))}FT`
|
||||
: vrt.act === 'VS' ? `${Math.round(num(V.apVsBug))}FPM`
|
||||
: vrt.act === 'FLC' ? `${Math.round(num(V.apSpdBug))}KT` : '';
|
||||
const cx = W / 2, yb = 98; // baseline
|
||||
return (
|
||||
<g fontFamily="monospace" fontSize="17">
|
||||
<rect x="150" y="78" width={W - 300} height="28" fill="#000" fillOpacity="0.55" />
|
||||
{/* AP / FD status (centre) */}
|
||||
<rect x={cx - 30} y="80" width="60" height="24" fill="none" stroke={ap ? '#16c116' : '#555'} strokeWidth="1.4" />
|
||||
<text x={cx - 16} y={yb} fill={ap ? '#16c116' : '#777'} textAnchor="middle">AP</text>
|
||||
<text x={cx + 16} y={yb} fill={fd ? '#16c116' : '#777'} textAnchor="middle">FD</text>
|
||||
{/* lateral: armed (white) then active (green) toward the centre */}
|
||||
{lat.arm && <text x={cx - 150} y={yb} fill="#fff" textAnchor="middle">{lat.arm}</text>}
|
||||
{lat.act && <text x={cx - 80} y={yb} fill="#16c116" textAnchor="middle" fontWeight="bold">{lat.act}</text>}
|
||||
{/* vertical: active (green) + reference, then armed (white) */}
|
||||
{vrt.act && <text x={cx + 78} y={yb} fill="#16c116" textAnchor="middle" fontWeight="bold">{vrt.act}</text>}
|
||||
{ref && <text x={cx + 150} y={yb} fill="#16c116" textAnchor="middle">{ref}</text>}
|
||||
{vrt.arm && <text x={cx + 230} y={yb} fill="#fff" textAnchor="middle">{vrt.arm === 'ALT' ? 'ALTS' : vrt.arm}</text>}
|
||||
</g>
|
||||
);
|
||||
}
|
||||
@@ -209,16 +418,46 @@ function Attitude({ V, svt }) {
|
||||
const pitch = num(V.pitch), roll = num(V.roll), slip = num(V.slip);
|
||||
const fdP = num(V.fdPitch), fdR = num(V.fdRoll);
|
||||
const cx = W / 2, cy = 270;
|
||||
const off = pitch * PITCH_PX;
|
||||
const rollRef = useRef(null), pitchRef = useRef(null), fdRef = useRef(null), bankRef = useRef(null);
|
||||
// Target attitude (updated every render); a rAF loop eases the displayed
|
||||
// transforms toward it — decoupled from X-Plane's ~20 Hz samples, so the
|
||||
// horizon glides instead of stepping. The easing is *time-based* (alpha from
|
||||
// dt and a time constant TAU), so it feels identical at 30/60/120 fps instead
|
||||
// of being faster on high-refresh screens.
|
||||
const tgt = useRef({ p: 0, r: 0, fp: 0, fr: 0 });
|
||||
tgt.current = { p: pitch, r: roll, fp: pitch - fdP, fr: roll - fdR };
|
||||
useEffect(() => {
|
||||
let raf, last = 0;
|
||||
const d = { ...tgt.current };
|
||||
const TAU = 0.09; // s — smoothing time constant (bigger = silkier, smaller = snappier)
|
||||
const loop = (now) => {
|
||||
const dt = last ? Math.min(0.05, (now - last) / 1000) : 0.016; // clamp tab-switch gaps
|
||||
last = now;
|
||||
const k = 1 - Math.exp(-dt / TAU); // frame-rate-independent easing factor
|
||||
const t = tgt.current;
|
||||
d.p += (t.p - d.p) * k; d.r += (t.r - d.r) * k;
|
||||
d.fp += (t.fp - d.fp) * k; d.fr += (t.fr - d.fr) * k;
|
||||
rollRef.current?.setAttribute('transform', `rotate(${-d.r} ${cx} ${cy})`);
|
||||
pitchRef.current?.setAttribute('transform', `translate(0 ${d.p * PITCH_PX})`);
|
||||
bankRef.current?.setAttribute('transform', `rotate(${-d.r} ${cx} ${cy})`);
|
||||
fdRef.current?.setAttribute('transform', `translate(0 ${d.fp * PITCH_PX}) rotate(${d.fr} ${cx} ${cy})`);
|
||||
raf = requestAnimationFrame(loop);
|
||||
};
|
||||
raf = requestAnimationFrame(loop);
|
||||
return () => cancelAnimationFrame(raf);
|
||||
}, []); // eslint-disable-line
|
||||
|
||||
return (
|
||||
<g>
|
||||
<defs>
|
||||
<clipPath id="att"><rect x={cx - 290} y={cy - 175} width={580} height={385} /></clipPath>
|
||||
{/* full-screen attitude, exactly like the SVT box: blue/brown fills the
|
||||
ENTIRE PFD below the radio bar (behind the tapes, HSI and data strip),
|
||||
same region as the 3D terrain so both look identical full-screen. */}
|
||||
<clipPath id="att"><rect x={SVT_BOX.x} y={SVT_BOX.y} width={SVT_BOX.w} height={SVT_BOX.h} /></clipPath>
|
||||
</defs>
|
||||
<g clipPath="url(#att)">
|
||||
<g transform={`rotate(${-roll} ${cx} ${cy})`}>
|
||||
<g transform={`translate(0 ${off})`}>
|
||||
<g ref={rollRef}>
|
||||
<g ref={pitchRef}>
|
||||
{/* sky/ground only when SVT is off — otherwise the 3D terrain shows */}
|
||||
{!svt && <rect x={cx - 800} y={cy - 1100} width={1600} height={1100} fill="url(#sky)" />}
|
||||
{!svt && <rect x={cx - 800} y={cy} width={1600} height={1100} fill="url(#ground)" />}
|
||||
@@ -226,17 +465,19 @@ function Attitude({ V, svt }) {
|
||||
{pitchLadder(cx, cy)}
|
||||
</g>
|
||||
</g>
|
||||
{/* flight director command bars (magenta) */}
|
||||
<g transform={`translate(0 ${(pitch - fdP) * PITCH_PX}) rotate(${roll - fdR} ${cx} ${cy})`}>
|
||||
<path d={`M${cx - 90} ${cy + 16} L${cx} ${cy - 6} L${cx + 90} ${cy + 16}`}
|
||||
fill="none" stroke="#e040fb" strokeWidth="6" strokeLinejoin="round" />
|
||||
{/* flight director command bars — magenta filled chevron (single cue) */}
|
||||
<g ref={fdRef} fill="#e24de0" stroke="#5a1a58" strokeWidth="1">
|
||||
<polygon points={`${cx - 5},${cy + 13} ${cx - 116},${cy + 45} ${cx - 5},${cy + 26}`} />
|
||||
<polygon points={`${cx + 5},${cy + 13} ${cx + 116},${cy + 45} ${cx + 5},${cy + 26}`} />
|
||||
</g>
|
||||
</g>
|
||||
{rollArc(cx, cy, roll, slip)}
|
||||
{/* fixed aircraft reference (yellow) */}
|
||||
<g stroke="#ffcc00" strokeWidth="6" fill="#111" strokeLinejoin="round">
|
||||
<path d={`M${cx - 150} ${cy} h60 l16 20 h-76 z`} />
|
||||
<path d={`M${cx + 150} ${cy} h-60 l-16 20 h76 z`} />
|
||||
{rollArc(cx, cy, slip, bankRef)}
|
||||
{/* fixed aircraft reference — yellow chevron (single cue) + side wing markers */}
|
||||
<g fill="#ffce00" stroke="#2a2200" strokeWidth="1">
|
||||
<polygon points={`${cx - 5},${cy} ${cx - 118},${cy + 30} ${cx - 5},${cy + 13}`} />
|
||||
<polygon points={`${cx + 5},${cy} ${cx + 118},${cy + 30} ${cx + 5},${cy + 13}`} />
|
||||
<polygon points={`158,${cy - 6} 192,${cy - 6} 204,${cy} 192,${cy + 6} 158,${cy + 6}`} />
|
||||
<polygon points={`${W - 158},${cy - 6} ${W - 192},${cy - 6} ${W - 204},${cy} ${W - 192},${cy + 6} ${W - 158},${cy + 6}`} />
|
||||
</g>
|
||||
{/* flight path marker (green) — track/AOA based; offset approximated */}
|
||||
{(() => {
|
||||
@@ -250,7 +491,6 @@ function Attitude({ V, svt }) {
|
||||
</g>
|
||||
);
|
||||
})()}
|
||||
{!svt && <rect x={cx - 290} y={cy - 175} width={580} height={385} fill="none" stroke="#000" strokeWidth="2" />}
|
||||
</g>
|
||||
);
|
||||
}
|
||||
@@ -273,7 +513,7 @@ function pitchLadder(cx, cy) {
|
||||
return <g>{m}</g>;
|
||||
}
|
||||
|
||||
function rollArc(cx, cy, roll, slip) {
|
||||
function rollArc(cx, cy, slip, bankRef) {
|
||||
const r = 165;
|
||||
const ticks = [-60, -45, -30, -20, -10, 0, 10, 20, 30, 45, 60];
|
||||
return (
|
||||
@@ -286,7 +526,7 @@ function rollArc(cx, cy, roll, slip) {
|
||||
x2={cx + r2 * Math.cos(a)} y2={cy + r2 * Math.sin(a)} stroke="#fff" strokeWidth={big ? 3 : 2} />;
|
||||
})}
|
||||
<path d={`M${cx} ${cy - r - 16} l-11 -16 h22 z`} fill="#fff" />
|
||||
<g transform={`rotate(${-roll} ${cx} ${cy})`}>
|
||||
<g ref={bankRef}>
|
||||
<path d={`M${cx} ${cy - r + 2} l-11 18 h22 z`} fill="#ffcc00" />
|
||||
<rect x={cx - 16 + slip * 7} y={cy - r + 22} width={32} height={9} rx={2} fill="#ffcc00" stroke="#000" />
|
||||
</g>
|
||||
@@ -298,15 +538,17 @@ function rollArc(cx, cy, roll, slip) {
|
||||
// V-speed reference marks for the C172 (KIAS), shown below the tape like the
|
||||
// XPLANE 1000: Vy=74 (Y), Vx=62 (X), best glide=68 (G).
|
||||
const VSPEEDS = [{ s: 74, l: 'Y' }, { s: 62, l: 'X' }, { s: 68, l: 'G' }];
|
||||
function AirspeedTape({ V }) {
|
||||
const ias = num(V.airspeed), tas = num(V.tas), spdBug = num(V.apSpdBug);
|
||||
const x = 60, top = 95, h = 350, cy = top + h / 2, px = 3.6;
|
||||
function AirspeedTape({ V, ias: iasProp }) {
|
||||
const ias = iasProp != null ? iasProp : num(V.airspeed);
|
||||
const tas = num(V.tas), spdBug = num(V.apSpdBug);
|
||||
const x = 60, top = 110, h = 350, cy = top + h / 2, px = 3.6;
|
||||
const W2 = 84, sx = x + W2 - 7; // colour strip at the right inner edge
|
||||
const ticks = [];
|
||||
const lo = Math.floor((ias - 50) / 10) * 10;
|
||||
for (let s = lo; s <= ias + 50; s += 10) {
|
||||
if (s < 0) continue;
|
||||
const y = cy + (ias - s) * px;
|
||||
if (y < top + 2 || y > top + h - 2) continue; // keep ticks inside the tape
|
||||
ticks.push(<g key={s}><line x1={x + 48} y1={y} x2={x + 60} y2={y} stroke="#fff" strokeWidth="2" />
|
||||
<text x={x + 42} y={y + 7} textAnchor="end" fill="#fff" fontSize="22" fontFamily="monospace">{s}</text></g>);
|
||||
}
|
||||
@@ -314,49 +556,76 @@ function AirspeedTape({ V }) {
|
||||
const band = (a, b, color) => <rect x={sx} y={yOf(b)} width={7} height={Math.max(0, yOf(a) - yOf(b))} fill={color} />;
|
||||
const bugY = Math.max(top, Math.min(top + h, cy + (ias - spdBug) * px));
|
||||
const valid = ias >= 20;
|
||||
// magenta airspeed trend vector: 6-second projection from acceleration
|
||||
// (smoothed dV/dt), exactly like the GDU 1040.
|
||||
const accRef = useRef({ t: 0, v: ias, a: 0 });
|
||||
const now = performance.now(), ar = accRef.current;
|
||||
if (ar.t) { const dt = (now - ar.t) / 1000; if (dt > 0.08) { ar.a = ar.a * 0.7 + ((ias - ar.v) / dt) * 0.3; ar.v = ias; ar.t = now; } }
|
||||
else { ar.t = now; ar.v = ias; }
|
||||
const trendY = yOf(ias + ar.a * 6);
|
||||
return (
|
||||
<g fontFamily="monospace">
|
||||
<rect x={x} y={top} width={W2} height={h} fill="#0e1626c8" />
|
||||
<rect x={x} y={top} width={W2} height={h} fill="#9aa6b3" fillOpacity="0.34" />
|
||||
{/* V-speed colour strip (white flap arc, green normal, yellow caution, red Vne) */}
|
||||
{band(33, 85, '#e8e8e8')}
|
||||
{band(48, 129, '#16c116')}
|
||||
{band(129, 163, '#e0d000')}
|
||||
<rect x={sx} y={yOf(180)} width={7} height={Math.max(0, yOf(163) - yOf(180))} fill="#d01010" />
|
||||
{ticks}
|
||||
{/* magenta speed trend vector (6-sec projection) */}
|
||||
{valid && Math.abs(trendY - cy) > 2 && (
|
||||
<rect x={x + W2 - 4} y={Math.min(cy, trendY)} width="4" height={Math.abs(trendY - cy)} fill="#ff20ff" />
|
||||
)}
|
||||
{/* selected-airspeed bug (cyan) */}
|
||||
<path d={`M${x + W2} ${bugY - 7} h-7 v14 h7 z`} fill="none" stroke="#0ff" strokeWidth="2" />
|
||||
{/* current-speed readout box (points right toward the tape) */}
|
||||
<polygon points={`${x + W2},${cy} ${x + W2 - 18},${cy - 22} ${x - 30},${cy - 22} ${x - 30},${cy + 22} ${x + W2 - 18},${cy + 22}`}
|
||||
fill="#000" stroke="#fff" strokeWidth="2" />
|
||||
<text x={x + W2 - 22} y={cy + 9} textAnchor="end" fill="#fff" fontSize="30" fontWeight="bold">{valid ? Math.round(ias) : '- - -'}</text>
|
||||
{/* V-speed reference list below the tape */}
|
||||
{/* V-speed reference bugs (Vy/Vx/Vg) below the tape — like the real G1000 */}
|
||||
{VSPEEDS.map((v, i) => (
|
||||
<g key={v.l}>
|
||||
<text x={x + 40} y={top + h + 24 + i * 24} textAnchor="end" fill="#0ff" fontSize="18">{v.s}</text>
|
||||
<rect x={x + 46} y={top + h + 10 + i * 24} width="18" height="18" fill="#0ff" />
|
||||
<text x={x + 55} y={top + h + 24 + i * 24} textAnchor="middle" fill="#000" fontSize="15" fontWeight="bold">{v.l}</text>
|
||||
<text x={x + 40} y={top + h + 25 + i * 22} textAnchor="end" fill="#0ff" fontSize="17">{v.s}</text>
|
||||
<rect x={x + 46} y={top + h + 12 + i * 22} width="17" height="17" fill="#0ff" />
|
||||
<text x={x + 54} y={top + h + 25 + i * 22} textAnchor="middle" fill="#000" fontSize="14" fontWeight="bold">{v.l}</text>
|
||||
</g>
|
||||
))}
|
||||
{/* TAS box at the very bottom */}
|
||||
<rect x={x} y={top + h + 84} width={W2} height={26} fill="#000" stroke="#3a3a3a" />
|
||||
<text x={x + 6} y={top + h + 103} fill="#0ff" fontSize="14">TAS</text>
|
||||
<text x={x + W2 - 6} y={top + h + 103} textAnchor="end" fill="#fff" fontSize="16">{Math.round(tas)}</text>
|
||||
{/* TAS box below the V-speeds */}
|
||||
<rect x={x} y={top + h + 80} width={W2} height={24} fill="#000" stroke="#3a3a3a" />
|
||||
<text x={x + 6} y={top + h + 97} fill="#0ff" fontSize="13">TAS</text>
|
||||
<text x={x + W2 - 6} y={top + h + 97} textAnchor="end" fill="#fff" fontSize="15">{Math.round(tas)}</text>
|
||||
</g>
|
||||
);
|
||||
}
|
||||
|
||||
/* ---------------- altitude tape + VSI + baro ---------------- */
|
||||
function AltitudeTape({ V }) {
|
||||
const alt = num(V.altitude), vs = num(V.vspeed), altBug = num(V.apAltBug), baro = num(V.baro, 29.92);
|
||||
const x = W - 70 - 84, W2 = 84, top = 95, h = 350, cy = top + h / 2, px = 0.42;
|
||||
function AltitudeTape({ V, alt: altProp, vs: vsProp, baroHpa = false, minimums }) {
|
||||
const alt = altProp != null ? altProp : num(V.altitude);
|
||||
const vs = vsProp != null ? vsProp : num(V.vspeed);
|
||||
const altBug = num(V.apAltBug), baro = num(V.baro, 29.92);
|
||||
const x = W - 70 - 84, W2 = 84, top = 110, h = 350, cy = top + h / 2, px = 0.42;
|
||||
const ticks = [];
|
||||
const lo = Math.floor((alt - 420) / 100) * 100;
|
||||
for (let a = lo; a <= alt + 420; a += 100) {
|
||||
const y = cy + (alt - a) * px;
|
||||
if (y < top + 2 || y > top + h - 2) continue; // keep ticks inside the tape
|
||||
ticks.push(<g key={a}><line x1={x + W2 - 18} y1={y} x2={x + W2 - 4} y2={y} stroke="#fff" strokeWidth="2" />
|
||||
<text x={x + W2 - 24} y={y + 7} textAnchor="end" fill="#fff" fontSize="19" fontFamily="monospace">{a}</text></g>);
|
||||
}
|
||||
const bugY = Math.max(top, Math.min(top + h, cy + (alt - altBug) * px));
|
||||
// magenta altitude trend vector: 6-second projection from vertical speed
|
||||
const trendY = Math.max(top, Math.min(top + h, cy + (alt - (alt + vs * 0.1)) * px));
|
||||
// altitude alerting (GDU 1040): within 1000 ft of the selected altitude the
|
||||
// box flashes cyan (approaching); within 200 ft it's captured; if it then
|
||||
// deviates >200 ft the readout turns amber. capRef remembers the capture.
|
||||
const capRef = useRef(false);
|
||||
const dAlt = altBug > 0 ? alt - altBug : null;
|
||||
const adA = dAlt == null ? Infinity : Math.abs(dAlt);
|
||||
if (adA <= 200) capRef.current = true;
|
||||
else if (adA > 1000) capRef.current = false;
|
||||
const approaching = dAlt != null && adA <= 1000 && adA > 200 && !capRef.current;
|
||||
const deviated = capRef.current && adA > 200;
|
||||
const selColor = deviated ? '#ffce46' : '#0ff';
|
||||
// rolling readout: leading hundreds (static) + a two-digit drum that *rolls*
|
||||
// through 20-ft steps, so you always see the value you're between — exactly
|
||||
// like the mechanical tens drum on the real GDU 1040.
|
||||
@@ -369,11 +638,18 @@ function AltitudeTape({ V }) {
|
||||
const drumX = x + W2 + 4, drumW = 26, drumCx = drumX + drumW / 2;
|
||||
return (
|
||||
<g fontFamily="monospace">
|
||||
{/* selected altitude (cyan) above the tape */}
|
||||
<rect x={x - 6} y={top - 32} width={W2 + 6} height={26} fill="#000" stroke="#0ff" strokeWidth="1.4" />
|
||||
<text x={x + W2 - 6} y={top - 13} textAnchor="end" fill="#0ff" fontSize="19">{selStr}</text>
|
||||
<rect x={x} y={top} width={W2} height={h} fill="#0e1626c8" />
|
||||
{/* selected altitude above the tape — flashes when approaching, amber on
|
||||
deviation after capture (altitude alerter) */}
|
||||
<g className={approaching || deviated ? 'alt-alert' : ''}>
|
||||
<rect x={x - 6} y={top - 32} width={W2 + 6} height={26} fill={deviated ? '#3a2e00' : '#000'} stroke={selColor} strokeWidth={approaching || deviated ? 2.2 : 1.4} />
|
||||
<text x={x + W2 - 6} y={top - 13} textAnchor="end" fill={selColor} fontSize="19">{selStr}</text>
|
||||
</g>
|
||||
<rect x={x} y={top} width={W2} height={h} fill="#9aa6b3" fillOpacity="0.34" />
|
||||
{ticks}
|
||||
{/* magenta altitude trend vector (6-sec projection) on the inner edge */}
|
||||
{Math.abs(vs) > 30 && Math.abs(trendY - cy) > 2 && (
|
||||
<rect x={x} y={Math.min(cy, trendY)} width="4" height={Math.abs(trendY - cy)} fill="#ff20ff" />
|
||||
)}
|
||||
{/* selected-altitude bug (cyan) on the tape */}
|
||||
<path d={`M${x} ${bugY - 7} h7 v14 h-7 z`} fill="none" stroke="#0ff" strokeWidth="2" />
|
||||
{/* current-altitude readout (points left toward the tape): static hundreds
|
||||
@@ -381,9 +657,9 @@ function AltitudeTape({ V }) {
|
||||
values are visible at once with the pointer between them (GDU 1040). */}
|
||||
<defs><clipPath id="altdrum"><rect x={drumX} y={cy - 22} width={drumW} height={44} /></clipPath></defs>
|
||||
<polygon points={`${x},${cy} ${x + 20},${cy - 24} ${drumX + drumW},${cy - 24} ${drumX + drumW},${cy + 24} ${x + 20},${cy + 24}`}
|
||||
fill="#000" stroke="#fff" strokeWidth="2" />
|
||||
<text x={drumX - 3} y={cy + 9} textAnchor="end" fill="#fff" fontSize="27" fontWeight="bold">{hi}</text>
|
||||
<g clipPath="url(#altdrum)" fill="#fff" fontSize="20" fontWeight="bold">
|
||||
fill="#000" stroke={deviated ? '#ffce46' : '#fff'} strokeWidth="2" />
|
||||
<text x={drumX - 3} y={cy + 9} textAnchor="end" fill={deviated ? '#ffce46' : '#fff'} fontSize="27" fontWeight="bold">{hi}</text>
|
||||
<g clipPath="url(#altdrum)" fill={deviated ? '#ffce46' : '#fff'} fontSize="20" fontWeight="bold">
|
||||
{[-1, 0, 1, 2].map((k) => {
|
||||
const v = base + k * STEP;
|
||||
const s = String(((v % 100) + 100) % 100).padStart(2, '0');
|
||||
@@ -392,7 +668,21 @@ function AltitudeTape({ V }) {
|
||||
</g>
|
||||
{/* baro */}
|
||||
<rect x={x} y={top + h + 10} width={W2} height={26} fill="#000" stroke="#3a3a3a" />
|
||||
<text x={x + W2 / 2} y={top + h + 29} textAnchor="middle" fill="#0ff" fontSize="16">{baro.toFixed(2)} IN</text>
|
||||
<text x={x + W2 / 2} y={top + h + 29} textAnchor="middle" fill="#0ff" fontSize="16">{baroHpa ? `${Math.round(baro * 33.8639)} HPA` : `${baro.toFixed(2)} IN`}</text>
|
||||
{/* barometric minimums (BARO MIN): cyan bug on the tape + readout, amber
|
||||
"MINIMUMS" annunciation when at/below the decision altitude */}
|
||||
{minimums?.on && (
|
||||
<g fontFamily="monospace">
|
||||
{(() => { const my = Math.max(top, Math.min(top + h, cy + (alt - minimums.ft) * px)); return (
|
||||
<g><path d={`M${x} ${my} l-9 -6 v12 z`} fill="#19b8e6" /><text x={x - 12} y={my + 5} textAnchor="end" fill="#19b8e6" fontSize="11" fontWeight="bold">B</text></g>
|
||||
); })()}
|
||||
<rect x={x} y={top + h + 40} width={W2} height="22" fill="#000" stroke="#19395a" />
|
||||
<text x={x + 4} y={top + h + 56} fill="#19b8e6" fontSize="12">BARO {minimums.ft}<tspan fill="#0c9" fontSize="10">FT</tspan></text>
|
||||
{alt > 0 && alt <= minimums.ft && (
|
||||
<text x={x + W2 / 2} y={cy + 46} textAnchor="middle" fill="#ffce46" fontSize="15" fontWeight="bold">MINIMUMS</text>
|
||||
)}
|
||||
</g>
|
||||
)}
|
||||
{/* VSI to the right */}
|
||||
<VSI x={x + W2 + 34} cy={cy} h={h} vs={vs} bug={num(V.apVsBug)} />
|
||||
</g>
|
||||
@@ -419,14 +709,26 @@ function VSI({ x, cy, h, vs, bug }) {
|
||||
}
|
||||
|
||||
/* ---------------- HSI compass rose ---------------- */
|
||||
function HSI({ V, nav }) {
|
||||
const hdg = ((num(V.heading) % 360) + 360) % 360;
|
||||
function HSI({ V, nav, hdg: hdgProp, obs = false }) {
|
||||
const hdg = hdgProp != null ? hdgProp : ((num(V.heading) % 360) + 360) % 360;
|
||||
const bug = num(V.apHdgBug);
|
||||
// With an active flight-plan leg the CDI follows OUR GPS guidance (desired
|
||||
// track + cross-track); otherwise it mirrors the sim's nav source.
|
||||
const crs = nav ? nav.dtk : num(V.obsCrs, 360);
|
||||
const def = nav ? nav.def : num(V.hsiDef);
|
||||
const toFrom = nav ? 1 : num(V.hsiToFrom);
|
||||
// CDI source mirrors the in-sim G1000: 2 = GPS (magenta), 0/1 = VLOC1/2 (green).
|
||||
// With GPS source + an active leg the CDI follows OUR GPS guidance (desired
|
||||
// track + cross-track); on VLOC it follows the sim's VOR/LOC needle.
|
||||
const src = Math.round(num(V.cdiSrc, 2)); // default GPS when unknown
|
||||
const isGps = src === 2;
|
||||
// OBS mode (GPS): the course is the pilot-set OBS course (CRS knob); cross-track
|
||||
// is measured against the OBS radial through the active waypoint. Sequencing is
|
||||
// suspended (the leg does not auto-advance).
|
||||
const obsActive = obs && isGps && !!nav;
|
||||
const useNav = isGps && !!nav && !obsActive;
|
||||
const C = isGps ? '#e040fb' : '#00d800'; // magenta GPS / green VLOC
|
||||
const srcLabel = obsActive ? 'OBS' : isGps ? 'GPS' : (src === 1 ? 'VLOC2' : 'VLOC1');
|
||||
const obsCrs = num(V.obsCrs, 360);
|
||||
const obsDef = obsActive ? Math.max(-2.5, Math.min(2.5, -(nav.dist * Math.sin((nav.brg - obsCrs) * D2R)) / 1.0)) : 0;
|
||||
const crs = obsActive ? obsCrs : useNav ? nav.dtk : obsCrs;
|
||||
const def = obsActive ? obsDef : useNav ? nav.def : num(V.hsiDef);
|
||||
const toFrom = (useNav || obsActive) ? 1 : num(V.hsiToFrom);
|
||||
const cx = W / 2, cy = 630, r = 130;
|
||||
|
||||
const ticks = [];
|
||||
@@ -458,30 +760,72 @@ function HSI({ V, nav }) {
|
||||
<g transform={`rotate(${bugA} ${cx} ${cy})`}>
|
||||
<path d={`M${cx} ${cy - r} l-10 -12 h6 v12 h8 v-12 h6 z`} fill="#0ff" stroke="#000" />
|
||||
</g>
|
||||
{/* GPS source label */}
|
||||
<text x={cx - 56} y={cy - 10} textAnchor="middle" fill="#e040fb" fontSize="15">GPS</text>
|
||||
<text x={cx + 56} y={cy - 10} textAnchor="middle" fill="#e040fb" fontSize="15">ENR</text>
|
||||
{/* course pointer + CDI (magenta = GPS source) */}
|
||||
{/* CDI source label (GPS magenta / VLOC green) */}
|
||||
<text x={cx - 56} y={cy - 10} textAnchor="middle" fill={C} fontSize="15">{srcLabel}</text>
|
||||
{isGps && <text x={cx + 56} y={cy - 10} textAnchor="middle" fill={C} fontSize="15">ENR</text>}
|
||||
{/* course pointer + CDI */}
|
||||
<g transform={`rotate(${crsA} ${cx} ${cy})`}>
|
||||
<line x1={cx} y1={cy - r + 18} x2={cx} y2={cy - 40} stroke="#e040fb" strokeWidth="4" />
|
||||
<polygon points={`${cx},${cy - r + 4} ${cx - 9},${cy - r + 22} ${cx + 9},${cy - r + 22}`} fill="#e040fb" />
|
||||
<line x1={cx} y1={cy + 40} x2={cx} y2={cy + r - 18} stroke="#e040fb" strokeWidth="4" />
|
||||
<line x1={cx} y1={cy - r + 18} x2={cx} y2={cy - 40} stroke={C} strokeWidth="4" />
|
||||
<polygon points={`${cx},${cy - r + 4} ${cx - 9},${cy - r + 22} ${cx + 9},${cy - r + 22}`} fill={C} />
|
||||
<line x1={cx} y1={cy + 40} x2={cx} y2={cy + r - 18} stroke={C} strokeWidth="4" />
|
||||
{/* CDI deviation bar */}
|
||||
<line x1={cx + defPx} y1={cy - 42} x2={cx + defPx} y2={cy + 42} stroke="#e040fb" strokeWidth="5" />
|
||||
<line x1={cx + defPx} y1={cy - 42} x2={cx + defPx} y2={cy + 42} stroke={C} strokeWidth="5" />
|
||||
{[-2, -1, 1, 2].map((d) => <circle key={d} cx={cx + d * 26} cy={cy} r={3.5} fill="none" stroke="#fff" strokeWidth="1.5" />)}
|
||||
{toFrom > 0 && <polygon points={toFrom === 1
|
||||
? `${cx},${cy - 60} ${cx - 9},${cy - 46} ${cx + 9},${cy - 46}`
|
||||
: `${cx},${cy + 60} ${cx - 9},${cy + 46} ${cx + 9},${cy + 46}`} fill="#e040fb" />}
|
||||
: `${cx},${cy + 60} ${cx - 9},${cy + 46} ${cx + 9},${cy + 46}`} fill={C} />}
|
||||
</g>
|
||||
{/* cyan bearing pointer to the active flight-plan waypoint (BRG) */}
|
||||
{/* bearing pointers — BRG1 = NAV1 (solid single needle), BRG2 = GPS active
|
||||
leg (hollow double needle). Both track the station/waypoint via the
|
||||
sim's bearing datarefs, so they stay in sync with the 3-D G1000. */}
|
||||
{num(V.nav1Dme) > 0 && (
|
||||
<g transform={`rotate(${num(V.nav1Brg) - hdg} ${cx} ${cy})`} stroke="#0ff" fill="#0ff">
|
||||
<polygon points={`${cx},${cy - r + 2} ${cx - 7},${cy - r + 20} ${cx + 7},${cy - r + 20}`} />
|
||||
<line x1={cx} y1={cy - r + 20} x2={cx} y2={cy - 36} strokeWidth="3" />
|
||||
<line x1={cx} y1={cy + 36} x2={cx} y2={cy + r - 6} strokeWidth="3" />
|
||||
</g>
|
||||
)}
|
||||
{nav && (
|
||||
<g transform={`rotate(${nav.brg - hdg} ${cx} ${cy})`}>
|
||||
<line x1={cx} y1={cy - r + 2} x2={cx} y2={cy - r + 30} stroke="#0ff" strokeWidth="3" />
|
||||
<polygon points={`${cx},${cy - r - 6} ${cx - 8},${cy - r + 12} ${cx + 8},${cy - r + 12}`} fill="none" stroke="#0ff" strokeWidth="2.5" />
|
||||
<line x1={cx} y1={cy + r - 30} x2={cx} y2={cy + r - 2} stroke="#0ff" strokeWidth="3" />
|
||||
<g transform={`rotate(${nav.brg - hdg} ${cx} ${cy})`} stroke="#0ff" fill="none" strokeWidth="2.5">
|
||||
<polygon points={`${cx},${cy - r + 2} ${cx - 8},${cy - r + 22} ${cx + 8},${cy - r + 22}`} />
|
||||
<line x1={cx - 3} y1={cy - r + 22} x2={cx - 3} y2={cy - 36} />
|
||||
<line x1={cx + 3} y1={cy - r + 22} x2={cx + 3} y2={cy - 36} />
|
||||
<line x1={cx - 3} y1={cy + 36} x2={cx - 3} y2={cy + r - 6} />
|
||||
<line x1={cx + 3} y1={cy + 36} x2={cx + 3} y2={cy + r - 6} />
|
||||
</g>
|
||||
)}
|
||||
<rect x={cx - 7} y={cy - 7} width={14} height={14} fill="#ffcc00" stroke="#000" strokeWidth="2" />
|
||||
{/* BRG info windows (lower corners): source + DME distance */}
|
||||
<BrgWindow x={150} y={cy + 36} n={1} src="NAV1" dist={num(V.nav1Dme)} solid />
|
||||
<BrgWindow x={W - 150} y={cy + 36} n={2} src="GPS" dist={nav ? nav.dist : 0} anchor="end" />
|
||||
</g>
|
||||
);
|
||||
}
|
||||
|
||||
// One BRG pointer info window (icon + source + DME distance), like the G1000's
|
||||
// lower-corner bearing readouts.
|
||||
function BrgWindow({ x, y, n, src, dist, solid = false, anchor = 'start' }) {
|
||||
if (!(dist > 0) && src !== 'GPS') return null;
|
||||
return (
|
||||
<g fontFamily="monospace" textAnchor={anchor}>
|
||||
<text x={x} y={y} fill="#0ff" fontSize="13">BRG{n}</text>
|
||||
<text x={x} y={y + 19} fill="#fff" fontSize="15">{src}</text>
|
||||
<text x={x} y={y + 38} fill="#0ff" fontSize="15">{dist > 0 ? `${dist.toFixed(1)}NM` : '– – –'}</text>
|
||||
</g>
|
||||
);
|
||||
}
|
||||
|
||||
// Marker-beacon annunciator (OM cyan / MM amber / IM white), upper-left.
|
||||
function Marker({ V }) {
|
||||
const im = num(V.mkrInner), mm = num(V.mkrMiddle), om = num(V.mkrOuter);
|
||||
const m = im > 0 ? { t: 'IM', c: '#000', bg: '#fff' }
|
||||
: mm > 0 ? { t: 'MM', c: '#000', bg: '#e0a000' }
|
||||
: om > 0 ? { t: 'OM', c: '#fff', bg: '#19d3ff' } : null;
|
||||
if (!m) return null;
|
||||
return (
|
||||
<g>
|
||||
<rect x={156} y={118} width={42} height={26} rx={13} fill={m.bg} stroke="#000" strokeWidth="1.5" />
|
||||
<text x={177} y={137} textAnchor="middle" fill={m.c} fontSize="16" fontWeight="bold" fontFamily="monospace">{m.t}</text>
|
||||
</g>
|
||||
);
|
||||
}
|
||||
@@ -562,6 +906,35 @@ function HdgCrsBoxes({ V, nav }) {
|
||||
);
|
||||
}
|
||||
|
||||
/* ---------------- wind box (lower-left), like the real G1000 ---------------- */
|
||||
function Wind({ V }) {
|
||||
const spd = Math.round(num(V.windSpd));
|
||||
const dir = ((Math.round(num(V.windDir)) % 360) + 360) % 360;
|
||||
const hdg = num(V.heading);
|
||||
const bx = 14, by = 636, bw = 128, bh = 92, cxw = bx + 30, cyw = by + 52;
|
||||
// arrow points the way the wind blows relative to the nose (from dir → +180)
|
||||
const rot = dir + 180 - hdg;
|
||||
return (
|
||||
<g fontFamily="monospace">
|
||||
<rect x={bx} y={by} width={bw} height={bh} rx="4" fill="#000a" stroke="#3a3a3a" />
|
||||
<text x={bx + bw / 2} y={by + 17} textAnchor="middle" fill="#9aa" fontSize="12">WIND</text>
|
||||
{spd >= 1 ? (
|
||||
<>
|
||||
<circle cx={cxw} cy={cyw} r="18" fill="none" stroke="#5a5f66" strokeWidth="1.5" />
|
||||
<g transform={`rotate(${rot} ${cxw} ${cyw})`} stroke="#fff" strokeWidth="3" fill="#fff">
|
||||
<line x1={cxw} y1={cyw + 16} x2={cxw} y2={cyw - 14} />
|
||||
<polygon points={`${cxw},${cyw - 20} ${cxw - 6},${cyw - 8} ${cxw + 6},${cyw - 8}`} stroke="none" />
|
||||
</g>
|
||||
<text x={bx + 58} y={by + 46} fill="#fff" fontSize="20">{String(dir).padStart(3, '0')}°</text>
|
||||
<text x={bx + 58} y={by + 72} fill="#fff" fontSize="20">{spd}<tspan fill="#9aa" fontSize="13">KT</tspan></text>
|
||||
</>
|
||||
) : (
|
||||
<text x={bx + bw / 2} y={by + 56} textAnchor="middle" fill="#6f808d" fontSize="14">NO WIND</text>
|
||||
)}
|
||||
</g>
|
||||
);
|
||||
}
|
||||
|
||||
/* ---------------- bottom data line: OAT / ISA / XPDR / LCL ---------------- */
|
||||
function DataStrip({ V }) {
|
||||
const oatC = num(V.oat);
|
||||
@@ -574,7 +947,6 @@ function DataStrip({ V }) {
|
||||
const lcl = `${String(now.getHours()).padStart(2, '0')}:${String(now.getMinutes()).padStart(2, '0')}:${String(now.getSeconds()).padStart(2, '0')}`;
|
||||
return (
|
||||
<g fontFamily="monospace" fontSize="17">
|
||||
<line x1="0" y1="730" x2={W} y2="730" stroke="#3a3a3a" strokeWidth="1.5" />
|
||||
{/* OAT + ISA (left) */}
|
||||
<rect x="14" y="742" width="118" height="26" fill="#000" stroke="#3a3a3a" />
|
||||
<text x="22" y="761" fill="#fff">OAT {oatF}°F</text>
|
||||
|
||||
@@ -21,6 +21,7 @@ export default function Proc({ xp, onClose }) {
|
||||
const [procs, setProcs] = useState(null);
|
||||
const [err, setErr] = useState('');
|
||||
const [cat, setCat] = useState('approach');
|
||||
const [view, setView] = useState('menu'); // 'menu' (PDF action list) | 'pick'
|
||||
const [selProc, setSelProc] = useState(null); // { name, transitions }
|
||||
const [selTrans, setSelTrans] = useState('');
|
||||
const [legs, setLegs] = useState([]);
|
||||
@@ -59,12 +60,39 @@ export default function Proc({ xp, onClose }) {
|
||||
onClose();
|
||||
};
|
||||
|
||||
const catLabel = CATS.find((c) => c.id === cat).label;
|
||||
|
||||
// The PDF's action menu. SELECT … opens our picker for that category;
|
||||
// ACTIVATE … are shown for authenticity (armed-procedure actions).
|
||||
if (view === 'menu') {
|
||||
const item = (label, onClick, sel) => (
|
||||
<button className={`proc-menu-i ${sel ? 'sel' : ''}`} onClick={onClick}>{label}</button>
|
||||
);
|
||||
const sel = (c) => { setCat(c); setSelProc(null); setSelTrans(''); setView('pick'); };
|
||||
return (
|
||||
<div className="gwin-backdrop" onClick={onClose}>
|
||||
<div className="dlg proc menu" onClick={(e) => e.stopPropagation()}>
|
||||
<div className="dlg-head">PROCEDURES</div>
|
||||
<div className="proc-menu">
|
||||
{item('ACTIVATE VECTOR-TO-FINAL', () => {})}
|
||||
{item('ACTIVATE APPROACH', () => {})}
|
||||
{item('ACTIVATE MISSED APPROACH', () => {})}
|
||||
{item('SELECT APPROACH', () => sel('approach'), true)}
|
||||
{item('SELECT ARRIVAL', () => sel('arrival'))}
|
||||
{item('SELECT DEPARTURE', () => sel('departure'))}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="dlg-backdrop" onClick={onClose}>
|
||||
<div className="gwin-backdrop" onClick={onClose}>
|
||||
<div className="dlg proc" onClick={(e) => e.stopPropagation()}>
|
||||
<div className="dlg-head">PROCEDURES</div>
|
||||
<div className="dlg-head">{catLabel}</div>
|
||||
<div className="proc-body">
|
||||
<div className="proc-apt">
|
||||
<button className="proc-back" onClick={() => setView('menu')}>‹</button>
|
||||
<label>APT</label>
|
||||
<input value={query} onChange={(e) => setQuery(e.target.value.toUpperCase())}
|
||||
onKeyDown={(e) => e.key === 'Enter' && setIcao(query)}
|
||||
@@ -73,13 +101,6 @@ export default function Proc({ xp, onClose }) {
|
||||
</div>
|
||||
{err && <div className="proc-err">{err}</div>}
|
||||
|
||||
<div className="proc-tabs">
|
||||
{CATS.map((c) => (
|
||||
<button key={c.id} className={cat === c.id ? 'on' : ''}
|
||||
onClick={() => { setCat(c.id); setSelProc(null); setSelTrans(''); }}>{c.label}</button>
|
||||
))}
|
||||
</div>
|
||||
|
||||
<div className="proc-cols">
|
||||
<div className="proc-list">
|
||||
<div className="proc-coltitle">{procs ? `${catList.length}` : '—'} PROC</div>
|
||||
|
||||
@@ -0,0 +1,48 @@
|
||||
import React from 'react';
|
||||
import { num } from '../api/useXplane.js';
|
||||
|
||||
// Touch-friendly radio tuner, styled like our KAP 140 (green LCD) + the desktop
|
||||
// launcher (macOS-dark chrome). Tunes the STANDBY frequency and swaps it active
|
||||
// via X-Plane's own per-radio commands (no unit-sensitive frequency writes).
|
||||
const fmt = (v, isCom) => (num(v) / 100).toFixed(isCom ? 3 : 2);
|
||||
|
||||
export default function RadioTuner({ values, command, radio, onClose }) {
|
||||
const { id, label, isCom } = radio;
|
||||
const sb = values[`${id}Sb`];
|
||||
const act = values[id];
|
||||
const cmd = (s) => command(`${id}${s}`);
|
||||
return (
|
||||
<div className="dlg-backdrop" onClick={onClose}>
|
||||
<div className="rtuner" onClick={(e) => e.stopPropagation()}>
|
||||
<div className="rt-head">
|
||||
<span className="rt-title">{label}</span>
|
||||
<span className="rt-kind">{isCom ? 'COM' : 'NAV'}</span>
|
||||
</div>
|
||||
|
||||
<div className="rt-lcd">
|
||||
<div className="rt-f"><span>ACTIVE</span><b className="act">{fmt(act, isCom)}</b></div>
|
||||
<button className="rt-swap" onClick={() => cmd('Swap')} title="Aktiv ↔ Standby">⇆</button>
|
||||
<div className="rt-f right"><span>STANDBY</span><b className="sby">{fmt(sb, isCom)}</b></div>
|
||||
</div>
|
||||
|
||||
<div className="rt-tune">
|
||||
<div className="rt-row">
|
||||
<span className="rt-unit">MHz</span>
|
||||
<button className="rt-step" onClick={() => cmd('CoarseDown')}>−</button>
|
||||
<button className="rt-step" onClick={() => cmd('CoarseUp')}>+</button>
|
||||
</div>
|
||||
<div className="rt-row">
|
||||
<span className="rt-unit">kHz</span>
|
||||
<button className="rt-step" onClick={() => cmd('FineDown')}>−</button>
|
||||
<button className="rt-step" onClick={() => cmd('FineUp')}>+</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="rt-actions">
|
||||
<button className="rt-btn primary" onClick={() => cmd('Swap')}>⇆ Auf Aktiv</button>
|
||||
<button className="rt-btn" onClick={onClose}>Schließen</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -19,14 +19,16 @@ function fmt(sec) {
|
||||
return h > 0 ? `${pad(h)}:${pad(m)}:${pad(ss)}` : `${pad(m)}:${pad(ss)}`;
|
||||
}
|
||||
|
||||
export default function TimerRef({ values, onClose }) {
|
||||
export default function TimerRef({ values, onClose, minimums = { on: false, ft: 500 }, onMinimums }) {
|
||||
const [dir, setDir] = useState('up'); // 'up' | 'dn'
|
||||
const [running, setRunning] = useState(false);
|
||||
const [elapsed, setElapsed] = useState(0); // seconds
|
||||
const [target, setTarget] = useState(300); // count-down start (s)
|
||||
const [vbugs, setVbugs] = useState({}); // key -> bool (shown on tape, future)
|
||||
const [minsOn, setMinsOn] = useState(false);
|
||||
const [mins, setMins] = useState(500); // baro minimums (ft)
|
||||
// Minimums are lifted to App so the PFD altimeter can show the BARO MIN bug.
|
||||
const minsOn = minimums.on, mins = minimums.ft;
|
||||
const setMins = (fn) => onMinimums && onMinimums((m) => ({ ...m, ft: Math.max(0, typeof fn === 'function' ? fn(m.ft) : fn) }));
|
||||
const setMinsOn = (fn) => onMinimums && onMinimums((m) => ({ ...m, on: typeof fn === 'function' ? fn(m.on) : fn }));
|
||||
const tickRef = useRef(null);
|
||||
|
||||
useEffect(() => {
|
||||
@@ -46,7 +48,6 @@ export default function TimerRef({ values, onClose }) {
|
||||
<div className="tmr-window">
|
||||
<div className="nrst-head">
|
||||
<span className="nrst-title">TIMER / REFERENCES</span>
|
||||
{onClose && <button className="nrst-x" onClick={onClose}>✕</button>}
|
||||
</div>
|
||||
<div className="tmr-body">
|
||||
<div className="tmr-clock">{fmt(shown)}</div>
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import React from 'react';
|
||||
import { num } from '../api/useXplane.js';
|
||||
import KAP140 from './KAP140.jsx';
|
||||
|
||||
// Classic analog "six-pack" VFR panel: airspeed, attitude, altimeter, turn
|
||||
// coordinator, heading indicator, vertical speed — round steam gauges driven by
|
||||
@@ -276,10 +277,12 @@ function Clock({ V }) {
|
||||
);
|
||||
}
|
||||
|
||||
export default function VFR({ values: V }) {
|
||||
export default function VFR({ xp }) {
|
||||
const V = xp.values;
|
||||
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 oilT = arr0(V.oilTemp), egtT = arr0(V.egt);
|
||||
const oilF = oilT > 150 ? oilT : oilT * 9 / 5 + 32, oilP = arr0(V.oilPress);
|
||||
const egtF = egtT > 900 ? egtT : egtT * 9 / 5 + 32, ffGph = (arr0(V.fuelFlow) * 3600) / KG_GAL;
|
||||
const amps = arr0(V.amps);
|
||||
return (
|
||||
<div className="vfr-panel">
|
||||
@@ -290,13 +293,14 @@ export default function VFR({ values: V }) {
|
||||
<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 className="vfr-tach"><Tach V={V} /></div>
|
||||
</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 className="vfr-ap"><KAP140 xp={xp} /></div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
+301
-49
@@ -6,15 +6,17 @@
|
||||
/* App chrome (everything that is NOT a G1000 instrument): same clean
|
||||
macOS-dark look as the desktop launcher. */
|
||||
--ui-font: 'Inter', -apple-system, BlinkMacSystemFont, 'SF Pro Text', 'Segoe UI', system-ui, sans-serif;
|
||||
--c-bg: #1c1c1e;
|
||||
--c-surface: #2c2c2e;
|
||||
--c-fill: #3a3a3c;
|
||||
--c-line: #48484a;
|
||||
--c-line-soft: #38383a;
|
||||
--c-txt: #ffffff;
|
||||
--c-txt2: #ebebf5;
|
||||
--c-mut: #8e8e93;
|
||||
--c-green: #30d158;
|
||||
/* monochrome chrome palette (191919 / 0f0f0f / f0f0f0) — no colour accent */
|
||||
--c-bg: #0f0f0f;
|
||||
--c-surface: #191919;
|
||||
--c-fill: #232323;
|
||||
--c-line: #3a3a3a;
|
||||
--c-line-soft: #2a2a2a;
|
||||
--c-txt: #f0f0f0;
|
||||
--c-txt2: #e0e0e0;
|
||||
--c-mut: #8a8a8a;
|
||||
--c-on: #f0f0f0; /* active/primary highlight (inverted) */
|
||||
--c-green: #30d158; /* kept only for the green LCD/display look */
|
||||
--c-amber: #ffd60a;
|
||||
--c-red: #ff453a;
|
||||
color-scheme: dark;
|
||||
@@ -54,7 +56,7 @@ body {
|
||||
}
|
||||
.sb-top:hover { background: #34343a; }
|
||||
.brand { font-weight: 700; font-size: 17px; letter-spacing: .3px; white-space: nowrap; }
|
||||
.brand span { color: var(--c-green); font-weight: 500; }
|
||||
.brand span { color: var(--c-mut); font-weight: 500; }
|
||||
.sb-chev { color: var(--c-mut); font-size: 12px; }
|
||||
.app.nav-narrow .brand span { display: none; }
|
||||
.app.nav-narrow .sb-chev { display: none; }
|
||||
@@ -70,7 +72,7 @@ body {
|
||||
transition: background .12s, color .12s;
|
||||
}
|
||||
.snav-i:hover { background: var(--c-surface); color: var(--c-txt2); }
|
||||
.snav-i.active { background: rgba(48,209,88,.16); color: var(--c-green); border-color: rgba(48,209,88,.35); }
|
||||
.snav-i.active { background: rgba(255,255,255,.09); color: var(--c-txt); border-color: #444; }
|
||||
.snav-ic { flex: 0 0 22px; }
|
||||
.snav-lbl { white-space: nowrap; }
|
||||
.app.nav-narrow .snav-lbl { display: none; }
|
||||
@@ -100,6 +102,39 @@ body {
|
||||
.vfr-gauge svg, .vfr-sg svg { width: 100%; height: auto; filter: drop-shadow(0 4px 12px rgba(0,0,0,.55)); }
|
||||
.vfr-name, .vfr-sname { font-family: var(--ui-font); letter-spacing: 1.2px; color: #c9d0d7; font-weight: 600; }
|
||||
.vfr-name { font-size: 11px; } .vfr-sname { font-size: 9px; }
|
||||
.vfr-ap { display: flex; justify-content: center; margin-top: clamp(8px, 1.5vw, 18px); }
|
||||
/* KAP 140 autopilot (steam-gauge C172) */
|
||||
.kap140 { display: flex; align-items: center; gap: 12px; background: linear-gradient(#2a2c30, #161719);
|
||||
border: 1px solid #0a0a0a; border-top: 1px solid #4a4d52; border-radius: 10px; padding: 11px 14px; font-family: var(--ui-font);
|
||||
box-shadow: 0 8px 24px rgba(0,0,0,.6), inset 0 1px 0 rgba(255,255,255,.06); }
|
||||
.kap-brand { color: #8893a0; font-size: 9px; font-weight: 700; letter-spacing: 1px; writing-mode: vertical-rl; transform: rotate(180deg); }
|
||||
.kap-lcd { background: #06160b; border: 1px solid #0a4d24; border-radius: 4px; padding: 7px 11px; min-width: 168px;
|
||||
box-shadow: inset 0 0 16px rgba(0,90,35,.5); font-family: 'Saira Semi Condensed', monospace; }
|
||||
.kap-l1 { display: flex; gap: 12px; align-items: center; }
|
||||
.kap-l1 .an { color: #0c3a1e; font-weight: 700; font-size: 14px; letter-spacing: 1px; }
|
||||
.kap-l1 .an.on { color: #3bff6e; text-shadow: 0 0 8px rgba(59,255,110,.5); }
|
||||
.kap-l2 { display: flex; gap: 14px; align-items: baseline; margin-top: 3px; color: #3bff6e; }
|
||||
.kap-l2 .big { font-size: 22px; font-weight: 700; }
|
||||
.kap-l2 .u { font-size: 10px; color: #1f9d52; margin-left: 2px; }
|
||||
.kap-l2 .vs { font-size: 14px; }
|
||||
.kap-keys { display: flex; gap: 7px; align-items: stretch; }
|
||||
/* physical, G1000-style buttons */
|
||||
.kap-btn { background: linear-gradient(#3b3e44, #23262b); color: #eef2f6; border: 1px solid #08090b; border-top: 1px solid #5c6168;
|
||||
border-radius: 7px; padding: 13px 11px; font-family: var(--ui-font); font-size: 12px; font-weight: 700; letter-spacing: .3px; cursor: pointer; min-width: 44px;
|
||||
box-shadow: 0 2px 4px rgba(0,0,0,.55), inset 0 1px 0 rgba(255,255,255,.10); }
|
||||
.kap-btn:hover { background: linear-gradient(#454951, #2a2d33); }
|
||||
.kap-btn:active { transform: translateY(1px); background: linear-gradient(#1a8f44, #136b32); color: #fff; box-shadow: inset 0 2px 5px rgba(0,0,0,.6); }
|
||||
.kap-btn.sm { padding: 6px 9px; font-size: 10px; min-width: 0; }
|
||||
.kap-updn { display: flex; flex-direction: column; gap: 4px; }
|
||||
/* clickable rotary: top half = up, bottom half = down */
|
||||
.kap-knob { display: flex; flex-direction: column; align-items: center; gap: 3px; }
|
||||
.kap-dial { position: relative; width: 50px; height: 50px; border-radius: 50%; cursor: pointer;
|
||||
background: radial-gradient(circle at 38% 30%, #4e535b, #14161a 72%); border: 1px solid #08090b;
|
||||
box-shadow: 0 2px 6px rgba(0,0,0,.6), inset 0 1px 0 rgba(255,255,255,.14);
|
||||
display: flex; flex-direction: column; align-items: center; justify-content: space-between; padding: 3px 0; }
|
||||
.kap-dial .kdir { color: #aeb6bf; font-size: 11px; line-height: 1; user-select: none; }
|
||||
.kap-dial:hover .kdir { color: #fff; }
|
||||
.kap-knoblbl { color: #8893a0; font-size: 9px; font-weight: 700; letter-spacing: 1px; }
|
||||
.vfr-clock { background: #0c0d0f; border: 1px solid #2a2f36; border-radius: 6px; padding: 8px 10px; display: flex; flex-direction: column; gap: 4px; }
|
||||
.vc-row { display: flex; align-items: baseline; justify-content: space-between; gap: 10px; }
|
||||
.vc-row b { font-family: 'Saira Condensed', monospace; color: #46e0c0; font-size: 18px; }
|
||||
@@ -124,16 +159,36 @@ body {
|
||||
.pfd-inset .mapwrap, .pfd-inset .leaflet-host { width: 100%; height: 100%; }
|
||||
.mapwrap.inset .leaflet-control-container { display: none; }
|
||||
/* NRST (nearest) window — pops over the right side of the PFD */
|
||||
/* G1000 windows: flat opaque rectangles embedded in the display — no shadow,
|
||||
no rounded corners, thin light border. They look part of the screen. */
|
||||
.nrst-window {
|
||||
position: absolute; z-index: 4; top: 9%; right: 1.5%; width: 41%; max-width: 440px;
|
||||
background: rgba(8, 10, 12, 0.94); border: 1px solid #4a5560; border-radius: 3px;
|
||||
color: #fff; font-family: 'Roboto Mono', monospace; box-shadow: 0 4px 18px rgba(0,0,0,0.6);
|
||||
position: absolute; z-index: 4; right: var(--gwin-right, 4%); bottom: var(--gwin-bottom, 6%); top: auto;
|
||||
width: var(--gwin-maxw, 290px); max-height: var(--gwin-maxh, 44%);
|
||||
display: flex; flex-direction: column;
|
||||
background: #05080b; border: 1px solid #7e8a94; border-radius: 0;
|
||||
color: #fff; font-family: 'Roboto Mono', monospace;
|
||||
}
|
||||
.nrst-list { flex: 1; }
|
||||
/* NEAREST AIRPORTS: two-line entries like the real GDU (ident/brg/dis/approach
|
||||
then com-type/freq/runway-length) */
|
||||
.apt-entry { padding: 4px 8px 5px; border-bottom: 1px solid #161b20; }
|
||||
.apt-l1 { display: grid; grid-template-columns: 1fr auto auto auto; align-items: baseline; column-gap: 8px; }
|
||||
.apt-l2 { display: grid; grid-template-columns: auto 1fr auto auto; align-items: baseline; column-gap: 6px; margin-top: 1px; }
|
||||
.apt-id { color: #36d2ff; font-size: 16px; font-weight: bold; justify-self: start; }
|
||||
.apt-id.cur { background: #19b8e6; color: #042230; padding: 0 4px; border-radius: 1px; }
|
||||
.apt-brg, .apt-dis { color: #fff; font-size: 14px; text-align: right; }
|
||||
.apt-dis u { color: #6f808d; font-size: 9px; text-decoration: none; margin-left: 1px; }
|
||||
.apt-app { color: #fff; font-size: 12px; min-width: 30px; text-align: right; }
|
||||
.apt-app.ils { color: #16d24a; }
|
||||
.apt-comlbl { color: #6f808d; font-size: 11px; }
|
||||
.apt-com { color: #fff; font-size: 13px; }
|
||||
.apt-rwlbl { color: #6f808d; font-size: 11px; }
|
||||
.apt-rw { color: #fff; font-size: 13px; text-align: right; }
|
||||
.nrst-head { display: flex; align-items: center; gap: 8px; padding: 5px 8px; background: #11161b; border-bottom: 1px solid #2c343c; }
|
||||
.nrst-title { color: #39d3c0; font-size: 13px; font-weight: bold; letter-spacing: 1px; }
|
||||
.nrst-title { color: #36d2ff; font-size: 13px; font-weight: bold; letter-spacing: 2px; }
|
||||
.nrst-tabs { display: flex; gap: 3px; margin-left: auto; }
|
||||
.nrst-tabs button { background: #1c242c; color: #9fb0bd; border: 1px solid #2c343c; border-radius: 2px; font: inherit; font-size: 11px; padding: 2px 9px; cursor: pointer; }
|
||||
.nrst-tabs button.on { background: #0c9; color: #04201c; border-color: #0c9; font-weight: bold; }
|
||||
.nrst-tabs button.on { background: #19b8e6; color: #042230; border-color: #19b8e6; font-weight: bold; }
|
||||
.nrst-x { background: none; border: none; color: #9fb0bd; cursor: pointer; font-size: 14px; padding: 0 2px; }
|
||||
.nrst-cols, .nrst-row { display: grid; grid-template-columns: 1.3fr 0.8fr 1fr 1.1fr; align-items: baseline; padding: 2px 8px; column-gap: 4px; }
|
||||
.nrst-cols { color: #6f808d; font-size: 10px; border-bottom: 1px solid #222; padding-bottom: 4px; }
|
||||
@@ -144,15 +199,111 @@ body {
|
||||
.nrst-row .c-xtra { color: #39d3c0; text-align: right; }
|
||||
.nrst-row .c-name { grid-column: 1 / -1; color: #8b9aa6; font-size: 10px; margin-top: -1px; }
|
||||
.nrst-list { max-height: 62vh; overflow-y: auto; }
|
||||
/* DME + ALERTS popups (PFD DME / CAUTION softkeys) — left side, G1000 style */
|
||||
.pfd-pop {
|
||||
position: absolute; z-index: 4; right: var(--gwin-right, 4%); bottom: var(--gwin-bottom, 6%); left: auto; top: auto;
|
||||
width: var(--gwin-maxw, 290px); max-height: var(--gwin-maxh, 44%);
|
||||
background: #05080b; border: 1px solid #7e8a94; border-radius: 0;
|
||||
color: #fff; font-family: 'Roboto Mono', monospace;
|
||||
}
|
||||
.pfd-pop.alerts { top: auto; }
|
||||
.pop-grid { display: grid; grid-template-columns: auto 1fr; gap: 4px 12px; padding: 8px 12px; font-size: 15px; }
|
||||
.pop-grid b { color: #6f808d; font-weight: normal; }
|
||||
.pop-grid span { color: #fff; text-align: right; }
|
||||
.pop-tabs { display: flex; gap: 3px; padding: 6px 8px; border-top: 1px solid #2c343c; }
|
||||
.pop-tabs button { flex: 1; background: #1c242c; color: #9fb0bd; border: 1px solid #2c343c; border-radius: 2px; font: inherit; font-size: 11px; padding: 3px 0; cursor: pointer; }
|
||||
.pop-tabs button.on { background: #0c9; color: #04201c; border-color: #0c9; font-weight: bold; }
|
||||
/* airway name labels on the MFD map */
|
||||
.awy-divicon { background: none; border: none; }
|
||||
.awy-lbl { color: #8fd0f0; font: 10px 'Roboto Mono', monospace; background: rgba(0,0,0,0.45); padding: 0 2px; border-radius: 2px; white-space: nowrap; }
|
||||
/* altitude alerter: flash the selected-altitude box (approaching / deviation) */
|
||||
.alt-alert { animation: altflash 1s steps(1, end) infinite; }
|
||||
@keyframes altflash { 50% { opacity: 0.25; } }
|
||||
.alerts-list { padding: 6px 0; max-height: 40vh; overflow-y: auto; }
|
||||
.alert-row { padding: 4px 12px; font-size: 14px; border-bottom: 1px solid #161b20; }
|
||||
.alert-row.warn { color: #ff5a4d; font-weight: bold; }
|
||||
.alert-row.cau { color: #ffce46; }
|
||||
.alert-none { padding: 10px 12px; color: #6f808d; font-size: 13px; }
|
||||
/* full-area NRST (MFD page) */
|
||||
.nrst-window.full { position: absolute; inset: 0; width: auto; max-width: none; border: none; border-radius: 0;
|
||||
background: #0a0d10; box-shadow: none; z-index: 660; display: flex; flex-direction: column; }
|
||||
.nrst-window.full .nrst-list { max-height: none; flex: 1; }
|
||||
/* FLIGHT PLAN page (MFD full / PFD window) */
|
||||
.fpl.full { position: absolute; inset: 0; z-index: 660; background: #0a0d10; display: flex; flex-direction: column; }
|
||||
.fpl.win { width: 320px; max-height: 44%; background: #05080b; border: 1px solid #7e8a94; border-radius: 0;
|
||||
display: flex; flex-direction: column; font-size: 13px; }
|
||||
.fpl-head { display: flex; align-items: center; gap: 8px; padding: 9px 12px; background: #0a0f14; border-bottom: 1px solid #2c343c;
|
||||
color: #36d2ff; font-family: var(--ui-font); font-weight: 700; font-size: 13px; letter-spacing: 2px; }
|
||||
.fpl-tot { margin-left: auto; color: #fff; font-size: 12px; font-family: 'Saira Condensed', monospace; }
|
||||
.fpl-x { background: none; border: none; color: #9fb0bd; cursor: pointer; font-size: 15px; padding: 0 2px; }
|
||||
.fpl-cols, .fpl-row { display: grid; grid-template-columns: 1.5fr .8fr .8fr .8fr .9fr 30px; align-items: center; gap: 6px; padding: 4px 12px; }
|
||||
.fpl-cols { color: #6f808d; font-size: 10px; border-bottom: 1px solid #222; letter-spacing: .5px; }
|
||||
.fpl-cols span:nth-child(n+2) { text-align: right; }
|
||||
.fpl-rows { flex: 1; overflow-y: auto; }
|
||||
.fpl-row { font-size: 16px; border-bottom: 1px solid #161b20; cursor: pointer; font-family: 'Saira Condensed', monospace; }
|
||||
.fpl-row:hover { background: #11161b; }
|
||||
.fpl-row.act { background: rgba(255,32,255,.12); }
|
||||
.fpl-row.sel { box-shadow: inset 0 0 0 1px #0ff; }
|
||||
.r-wpt { color: #0ff; font-weight: 700; } .r-wpt i { color: #0a8; font-style: normal; font-size: 10px; margin-left: 6px; }
|
||||
.r-wpt b { font-weight: 700; } .r-wpt b.cur { background: #19b8e6; color: #042230; padding: 0 4px; border-radius: 1px; }
|
||||
.r-dtk, .r-dis, .r-cum { color: #e7edf2; text-align: right; }
|
||||
.r-alt { color: #6f808d; text-align: right; }
|
||||
.r-alt.dsgn { color: #4fa8ff; } /* designated (VNAV) altitude = blue, per manual S.105 */
|
||||
/* CURRENT VNV PROFILE panel (MFD flight-plan page) */
|
||||
.fpl-vnav { border-top: 1px solid #2c343c; padding: 6px 12px 8px; font-family: 'Roboto Mono', monospace; }
|
||||
.fpl-vnav-h { color: #36d2ff; font-size: 11px; letter-spacing: 1px; margin-bottom: 5px; }
|
||||
.fpl-vnav-grid { display: grid; grid-template-columns: auto 1fr auto 1fr; gap: 3px 10px; align-items: baseline; }
|
||||
.fpl-vnav-grid b { color: #6f808d; font-weight: normal; font-size: 11px; }
|
||||
.fpl-vnav-grid span { color: #fff; font-size: 14px; }
|
||||
.fpl-vnav-grid span u { color: #6f808d; font-size: 9px; text-decoration: none; margin-left: 1px; }
|
||||
.fpl-vnav-grid .vwpt { color: #4fa8ff; }
|
||||
.fpl-vnav-none { color: #6f808d; font-size: 12px; }
|
||||
/* ORIG / DEST subtitle (PFD window) */
|
||||
.fpl-od { color: #36d2ff; text-align: center; font-family: 'Roboto Mono', monospace; font-size: 14px; padding: 3px 0; border-bottom: 1px solid #1c242c; letter-spacing: 1px; }
|
||||
/* compact window: DTK/DIS only (drop CUM/ALT), no editor — like the real FPL window */
|
||||
.fpl.win .fpl-cols span:nth-child(4), .fpl.win .fpl-cols span:nth-child(5),
|
||||
.fpl.win .r-cum, .fpl.win .r-alt, .fpl.win .r-del, .fpl.win .fpl-entry { display: none; }
|
||||
.fpl.win .fpl-cols, .fpl.win .fpl-row { grid-template-columns: 1.4fr .8fr 1fr; }
|
||||
.fpl.win .fpl-row { font-size: 15px; }
|
||||
.fpl-row.act .r-wpt, .fpl-row.act .r-dtk, .fpl-row.act .r-dis, .fpl-row.act .r-cum, .fpl-row.act .r-alt { color: #ff5bff; }
|
||||
.r-del { background: none; border: none; color: #c44; font-size: 15px; cursor: pointer; }
|
||||
.fpl-empty { color: #6f808d; text-align: center; padding: 18px; font-size: 13px; font-family: var(--ui-font); }
|
||||
.fpl-entry { border-top: 1px solid #2c343c; padding: 10px 12px; background: #0c1116; font-family: var(--ui-font); }
|
||||
.fpl-hits { display: flex; flex-direction: column; gap: 3px; margin-bottom: 8px; }
|
||||
.fpl-hits button { display: flex; align-items: baseline; gap: 8px; background: #141a20; border: 1px solid #222b33; color: #cfd6dd; font: inherit; padding: 5px 8px; cursor: pointer; text-align: left; }
|
||||
.fpl-hits b { color: #0ff; } .fpl-hits i { color: #0a8; font-style: normal; font-size: 11px; } .fpl-hits span { color: #6f808d; font-size: 11px; margin-left: auto; }
|
||||
.fpl-inrow { display: flex; gap: 8px; }
|
||||
.fpl-inrow input { flex: 1; background: #05080b; border: 1px solid #2c343c; color: #0ff; font: inherit; font-size: 15px; letter-spacing: 1px; padding: 9px 10px; text-transform: uppercase; }
|
||||
.fpl-actions { display: flex; gap: 8px; margin-top: 8px; }
|
||||
.fpl-btn { flex: 1; background: #232323; color: #e0e0e0; border: 1px solid #3a3a3a; border-radius: 8px; padding: 9px; font: inherit; font-weight: 700; font-size: 13px; cursor: pointer; }
|
||||
.fpl-btn.add { background: #f0f0f0; color: #0f0f0f; border-color: transparent; flex: 0 0 auto; padding: 9px 16px; }
|
||||
.fpl-btn:hover:not(:disabled) { filter: brightness(1.15); } .fpl-btn:disabled { opacity: .4; cursor: default; }
|
||||
.fpl-msg { margin-top: 6px; font-size: 12px; } .fpl-msg.ok { color: #39d3c0; } .fpl-msg.err { color: #ffae42; }
|
||||
/* saved-plans load picker */
|
||||
.fpl-load { position: absolute; inset: 0; z-index: 10; background: #000a; display: flex; align-items: center; justify-content: center; }
|
||||
.fpl-load-box { width: min(380px, 90%); max-height: 80%; background: #0c1116; border: 1px solid #2c343c; border-radius: 10px; display: flex; flex-direction: column; box-shadow: 0 12px 36px rgba(0,0,0,.6); }
|
||||
.fpl-load-head { display: flex; align-items: center; justify-content: space-between; padding: 9px 12px; background: #11161b; border-bottom: 1px solid #2c343c; color: #39d3c0; font-family: var(--ui-font); font-weight: 700; font-size: 13px; }
|
||||
.fpl-load-head button { background: none; border: none; color: #9fb0bd; cursor: pointer; font-size: 14px; }
|
||||
.fpl-load-list { overflow-y: auto; padding: 6px; display: flex; flex-direction: column; gap: 4px; }
|
||||
.fpl-load-list button { background: #141a20; border: 1px solid #222b33; color: #0ff; font-family: 'Saira Condensed', monospace; font-size: 16px; font-weight: 700; text-align: left; padding: 9px 12px; border-radius: 6px; cursor: pointer; }
|
||||
.fpl-load-list button:hover { background: #1c252e; }
|
||||
.fpl-load-list button i { color: #6f808d; font-style: normal; font-size: 11px; margin-left: 6px; }
|
||||
/* page-group indicator (bottom-right), like the real G1000; tap = next page */
|
||||
.mfd-pageind { position: absolute; right: 6px; bottom: 6px; z-index: 700; display: flex; align-items: center; gap: 6px;
|
||||
background: #000a; border: 1px solid #2c343c; border-radius: 4px; padding: 5px 8px; cursor: pointer;
|
||||
font: 700 12px/1 monospace; color: #0ff; }
|
||||
.mfd-pageind em { width: 8px; height: 8px; border: 1px solid #0ff; display: inline-block; }
|
||||
.mfd-pageind em.on { background: #0ff; }
|
||||
.nrst-empty { color: #6f808d; text-align: center; padding: 12px; font-size: 12px; }
|
||||
/* XPDR squawk-entry readout above the softkey keypad */
|
||||
.squawk-entry { text-align: center; color: #9fb0bd; font-family: 'Roboto Mono', monospace; font-size: 13px; letter-spacing: 1px; padding: 3px 0; }
|
||||
.squawk-entry b { color: #19ff19; font-size: 18px; letter-spacing: 5px; margin-left: 6px; }
|
||||
/* TMR/REF window — left side of the PFD */
|
||||
.tmr-window {
|
||||
position: absolute; z-index: 4; top: 9%; left: 1.5%; width: 30%; max-width: 320px;
|
||||
background: rgba(8, 10, 12, 0.94); border: 1px solid #4a5560; border-radius: 3px;
|
||||
color: #fff; font-family: 'Roboto Mono', monospace; box-shadow: 0 4px 18px rgba(0,0,0,0.6);
|
||||
position: absolute; z-index: 4; right: var(--gwin-right, 4%); bottom: var(--gwin-bottom, 6%);
|
||||
width: var(--gwin-maxw, 290px); max-height: var(--gwin-maxh, 44%); overflow-y: auto;
|
||||
background: #05080b; border: 1px solid #7e8a94; border-radius: 0;
|
||||
color: #fff; font-family: 'Roboto Mono', monospace;
|
||||
}
|
||||
.tmr-body { padding: 8px 10px; }
|
||||
.tmr-clock { font-size: 34px; font-weight: bold; text-align: center; color: #fff; letter-spacing: 2px; }
|
||||
@@ -177,24 +328,40 @@ body {
|
||||
.tmr-minalert { margin-top: 6px; text-align: center; background: #ffd24a; color: #1a1400; font-weight: bold; padding: 3px; border-radius: 2px; letter-spacing: 2px; }
|
||||
/* Modal dialogs (Direct-To, …) */
|
||||
.dlg-backdrop { position: fixed; inset: 0; z-index: 20; background: rgba(0,0,0,0.55); display: flex; align-items: center; justify-content: center; }
|
||||
.dlg { background: #0c1015; border: 1px solid #3a4651; border-radius: 5px; min-width: 320px; color: #fff; font-family: 'Roboto Mono', monospace; box-shadow: 0 8px 30px rgba(0,0,0,0.7); }
|
||||
.dlg-head { background: #11161b; padding: 8px 12px; border-bottom: 1px solid #2c343c; color: #fff; font-weight: bold; letter-spacing: 1px; border-radius: 5px 5px 0 0; }
|
||||
/* G1000 side-window dialogs (PROC / Direct-To / FPL): compact panels in the
|
||||
display's lower-right, no screen dimming — like the real unit. */
|
||||
.gwin-backdrop { position: absolute; inset: 0; z-index: 20; background: transparent; display: flex; align-items: flex-end; justify-content: flex-end; padding: 0 var(--gwin-right, 4%) var(--gwin-bottom, 6%) 0; }
|
||||
.dlg { background: #05080b; border: 1px solid #7e8a94; border-radius: 0; min-width: 0; color: #fff; font-family: 'Roboto Mono', monospace; }
|
||||
/* G1000 side-windows fill the lower-right zone (clear of HSI + baro box) */
|
||||
.gwin-backdrop .dlg, .fpl.win { width: var(--gwin-maxw, 290px); max-width: var(--gwin-maxw, 290px); max-height: var(--gwin-maxh, 44%); }
|
||||
.dlg-head { background: #0a0f14; padding: 6px 12px; border-bottom: 1px solid #2c343c; color: #36d2ff; font-weight: bold; letter-spacing: 2px; text-align: center; border-radius: 2px 2px 0 0; }
|
||||
.dto-arrow { color: #e040fb; margin-right: 8px; }
|
||||
.dto-body { padding: 12px; }
|
||||
.dto-lbl { color: #6f808d; font-size: 11px; display: block; margin-bottom: 4px; }
|
||||
.dto-input { width: 100%; box-sizing: border-box; background: #05080b; border: 1px solid #2c343c; color: #0ff; font: inherit; font-size: 20px; letter-spacing: 3px; padding: 6px 10px; text-transform: uppercase; }
|
||||
.dto-hits { display: flex; flex-direction: column; gap: 3px; margin-top: 6px; }
|
||||
.dto-hits button { display: flex; align-items: baseline; gap: 8px; background: #141a20; border: 1px solid #222b33; color: #cfd6dd; font: inherit; padding: 5px 8px; cursor: pointer; text-align: left; }
|
||||
.dto-hits button.on { border-color: #0c9; background: #0a1f1b; }
|
||||
.dto-hits button b { color: #0ff; } .dto-hits button i { color: #0a8; font-style: normal; font-size: 11px; } .dto-hits button span { color: #6f808d; font-size: 11px; margin-left: auto; }
|
||||
.dto-sel { display: flex; align-items: baseline; gap: 10px; margin-top: 10px; padding-top: 8px; border-top: 1px solid #222; }
|
||||
.dto-sel .dto-id { color: #0ff; font-size: 20px; font-weight: bold; }
|
||||
.dto-sel .dto-type { color: #0a8; font-size: 11px; }
|
||||
.dto-sel .dto-vec { color: #e040fb; margin-left: auto; font-weight: bold; }
|
||||
.dto-body { padding: 10px 12px 12px; }
|
||||
.dto-ident { display: block; width: 100%; box-sizing: border-box; background: none; border: none; border-bottom: 1px solid #2c343c;
|
||||
color: #36d2ff; font: inherit; font-size: 24px; font-weight: bold; letter-spacing: 3px; padding: 2px 2px 4px; text-transform: uppercase; outline: none; }
|
||||
.dto-ident::placeholder { color: #2c4a57; }
|
||||
.dto-name { color: #cdd6dd; font-size: 13px; min-height: 17px; padding: 3px 2px 0; }
|
||||
.dto-hits { display: flex; flex-direction: column; gap: 2px; margin-top: 5px; }
|
||||
.dto-hits button { display: flex; align-items: baseline; gap: 10px; background: #0c1116; border: 1px solid #1c242c; color: #cfd6dd; font: inherit; padding: 4px 8px; cursor: pointer; text-align: left; }
|
||||
.dto-hits button:hover { background: #13202a; border-color: #36d2ff; }
|
||||
.dto-hits button b { color: #36d2ff; } .dto-hits button span { color: #6f808d; font-size: 11px; margin-left: auto; }
|
||||
.dto-grid { display: grid; grid-template-columns: auto 1fr auto 1fr; align-items: baseline; gap: 7px 8px; margin-top: 10px; padding-top: 9px; border-top: 1px solid #222; }
|
||||
.dto-grid b { color: #6f808d; font-weight: normal; font-size: 12px; }
|
||||
.dto-grid span { color: #fff; font-size: 15px; }
|
||||
.dto-foot { display: flex; justify-content: flex-end; margin-top: 12px; }
|
||||
.dto-act { background: #0c1116; border: 1px solid #7e8a94; color: #36d2ff; font: inherit; font-weight: bold; letter-spacing: 1px; font-size: 14px; padding: 5px 14px; cursor: pointer; }
|
||||
.dto-act:hover:not(:disabled) { background: #19b8e6; color: #042230; border-color: #19b8e6; }
|
||||
.dto-act:disabled { opacity: .4; cursor: default; }
|
||||
.dlg-actions { display: flex; gap: 8px; padding: 10px 12px; border-top: 1px solid #2c343c; }
|
||||
.dlg-actions .fbtn { flex: 1; }
|
||||
/* PROC dialog */
|
||||
.dlg.proc { width: 640px; max-width: 92vw; }
|
||||
.dlg.proc, .dlg.proc.menu { width: var(--gwin-maxw, 290px); max-width: var(--gwin-maxw, 290px); display: flex; flex-direction: column; }
|
||||
.dlg.proc .proc-body { flex: 1; min-height: 0; display: flex; flex-direction: column; }
|
||||
.proc-menu { display: flex; flex-direction: column; padding: 4px 0; }
|
||||
.proc-menu-i { background: none; border: none; border-bottom: 1px solid #11161b; color: #d7e2ea; font: inherit; font-family: 'Roboto Mono', monospace; font-size: 13px; text-align: left; padding: 6px 12px; cursor: pointer; letter-spacing: .5px; }
|
||||
.proc-menu-i:hover { background: #11161b; }
|
||||
.proc-menu-i.sel { background: #19b8e6; color: #042230; font-weight: bold; }
|
||||
.proc-back { background: #1c242c; border: 1px solid #2c343c; color: #36d2ff; font: inherit; font-size: 14px; line-height: 1; padding: 4px 9px; cursor: pointer; border-radius: 2px; }
|
||||
.proc-body { padding: 12px; }
|
||||
.proc-apt { display: flex; align-items: center; gap: 8px; }
|
||||
.proc-apt label { color: #6f808d; font-size: 11px; }
|
||||
@@ -203,13 +370,13 @@ body {
|
||||
.proc-err { color: #ffae42; font-size: 12px; margin-top: 6px; }
|
||||
.proc-tabs { display: flex; gap: 4px; margin: 10px 0 6px; }
|
||||
.proc-tabs button { flex: 1; background: #1c242c; color: #9fb0bd; border: 1px solid #2c343c; font: inherit; font-size: 12px; padding: 5px; cursor: pointer; }
|
||||
.proc-tabs button.on { background: #0c9; color: #04201c; font-weight: bold; border-color: #0c9; }
|
||||
.proc-cols { display: grid; grid-template-columns: 1fr 1fr 1.4fr; gap: 6px; height: 300px; }
|
||||
.proc-tabs button.on { background: #19b8e6; color: #042230; font-weight: bold; border-color: #19b8e6; }
|
||||
.proc-cols { display: grid; grid-template-columns: 1fr 1fr 1.3fr; gap: 5px; flex: 1; min-height: 0; }
|
||||
.proc-list, .proc-preview { background: #05080b; border: 1px solid #1c242c; overflow-y: auto; display: flex; flex-direction: column; }
|
||||
.proc-coltitle { position: sticky; top: 0; background: #11161b; color: #6f808d; font-size: 10px; padding: 4px 8px; border-bottom: 1px solid #222; }
|
||||
.proc-list button { background: none; border: none; border-bottom: 1px solid #11161b; color: #cfd6dd; font: inherit; font-size: 14px; text-align: left; padding: 6px 8px; cursor: pointer; }
|
||||
.proc-list button:hover { background: #11161b; }
|
||||
.proc-list button.on { background: #0a1f1b; color: #0ff; font-weight: bold; }
|
||||
.proc-list button.on { background: #19b8e6; color: #042230; font-weight: bold; }
|
||||
.proc-empty { color: #6f808d; font-size: 11px; padding: 8px; }
|
||||
.proc-leg { display: flex; align-items: baseline; gap: 8px; padding: 5px 8px; border-bottom: 1px solid #11161b; font-size: 13px; }
|
||||
.proc-leg b { color: #0ff; } .proc-leg u { color: #39d3c0; font-size: 10px; text-decoration: none; margin-left: auto; }
|
||||
@@ -225,7 +392,7 @@ body {
|
||||
|
||||
/* ---- GDU-1040 bezel ---- */
|
||||
.bezel {
|
||||
width: 100%; height: 100%; display: flex; align-items: stretch; gap: 0;
|
||||
width: 100%; height: 100%; display: flex; align-items: stretch; gap: 12px;
|
||||
background: linear-gradient(150deg, #3a3c40, #202123 55%, #2c2d30);
|
||||
border-radius: 18px; padding: 12px; box-shadow: inset 0 1px 0 #4a4c50, 0 8px 30px #000;
|
||||
font-family: 'Saira Semi Condensed', sans-serif;
|
||||
@@ -233,16 +400,24 @@ body {
|
||||
.bezel-core { flex: 1; display: flex; flex-direction: column; min-width: 0; }
|
||||
.bezel-title { text-align: center; color: #c9ced3; font-size: 14px; font-weight: 700; letter-spacing: 3px; padding: 2px 0 6px; }
|
||||
.bezel-screen {
|
||||
flex: 1; background: #000; border-radius: 6px; overflow: hidden; position: relative;
|
||||
border: 2px solid #0a0a0a; box-shadow: inset 0 0 18px #000, inset 0 0 2px #1a3a5a;
|
||||
display: flex; min-height: 0;
|
||||
flex: 1; background: #000; overflow: hidden; position: relative;
|
||||
display: flex; flex-direction: column; min-height: 0;
|
||||
}
|
||||
.bezel-screen > * { width: 100%; height: 100%; }
|
||||
.softkeys { display: grid; grid-template-columns: repeat(12, 1fr); gap: 6px; padding: 8px 2px 2px; }
|
||||
.screen-content { flex: 1; min-height: 0; width: 100%; position: relative; }
|
||||
.screen-content > * { width: 100%; height: 100%; }
|
||||
/* on-screen softkey label row (lowest line of the display) */
|
||||
.sk-labels { flex: 0 0 auto; display: grid; grid-template-columns: repeat(12, 1fr); gap: 4px;
|
||||
padding: 3px 2px; background: #000; border-top: 1px solid #1c1c1c; }
|
||||
.skl { display: flex; align-items: center; justify-content: center; height: 20px; border-radius: 3px;
|
||||
color: #e8edf2; font-size: 11px; font-weight: 700; letter-spacing: .3px; font-family: 'Saira Semi Condensed', sans-serif; }
|
||||
.skl.empty { color: transparent; }
|
||||
.skl.on { background: #e8edf2; color: #0a0c0e; }
|
||||
.skl.caution { background: linear-gradient(#ffd23a, #e0b400); color: #1a1b1e; }
|
||||
.softkeys { display: grid; grid-template-columns: repeat(12, 1fr); gap: 4px; padding: 4px 2px 1px; }
|
||||
.softkey {
|
||||
height: 30px; display: flex; align-items: center; justify-content: center;
|
||||
height: 20px; display: flex; align-items: center; justify-content: center;
|
||||
background: linear-gradient(#202224, #131416); border: 1px solid #000; border-top: 1px solid #45474b;
|
||||
border-radius: 4px; color: #cfd6dc; font-size: 12px; font-weight: 600; letter-spacing: .3px;
|
||||
border-radius: 3px; color: #cfd6dc; font-size: 10.5px; font-weight: 600; letter-spacing: .2px;
|
||||
box-shadow: 0 1px 2px #000; cursor: pointer; font-family: inherit;
|
||||
}
|
||||
.softkey:not(.empty):hover { background: linear-gradient(#2a2c2f, #1a1b1e); border-top-color: #5a5d61; }
|
||||
@@ -251,19 +426,33 @@ body {
|
||||
.softkey.on { background: #e8edf2; color: #0a0c0e; border-top-color: #fff; font-weight: 800; }
|
||||
.softkey.caution { color: #1a1b1e; background: linear-gradient(#ffd23a, #e0b400); border-top-color: #fff2a8; font-weight: 800; }
|
||||
|
||||
.bezel-knobs { display: flex; flex-direction: column; align-items: center; justify-content: space-around; padding: 4px 6px; gap: 6px; }
|
||||
.bezel-knobs.left { width: 88px; } .bezel-knobs.right { width: 100px; }
|
||||
.bezel-knobs { display: flex; flex-direction: column; align-items: center; padding: 12px 6px; gap: 14px; flex: 0 0 104px; width: 104px; }
|
||||
/* NAV/HDG (+ AP block on MFD) group at the top, ALT pinned to the bottom */
|
||||
.bezel-knobs.left { justify-content: flex-start; }
|
||||
.bezel-knobs.left > .knob-wrap:last-child { margin-top: auto; }
|
||||
/* COM at top … FMS at bottom, evenly spread */
|
||||
.bezel-knobs.right { justify-content: space-between; }
|
||||
.knob-wrap { display: flex; flex-direction: column; align-items: center; gap: 2px; position: relative; }
|
||||
.knob-lbl { color: #d2d7dc; font-size: 12px; font-weight: 800; letter-spacing: 1px; }
|
||||
.knob-sub { color: #8b9197; font-size: 8.5px; font-weight: 600; letter-spacing: .3px; text-align: center; }
|
||||
.knob-extra { position: absolute; right: -10px; top: 6px; width: 20px; height: 16px; background: #1a1b1e; border: 1px solid #000; border-radius: 3px; color: #cfd6dc; font-size: 11px; text-align: center; line-height: 16px; }
|
||||
.knob-swap { position: absolute; right: 2px; top: 0; width: 26px; height: 20px; background: linear-gradient(#2a2c2f, #16171a); border: 1px solid #000; border-top: 1px solid #45474b; border-radius: 4px; color: #0ff; font-size: 13px; cursor: pointer; padding: 0; line-height: 1; }
|
||||
.knob-swap:active { background: #000; }
|
||||
.knob-emerg { position: absolute; left: 2px; top: 2px; color: #c33; font-size: 8px; font-weight: 700; letter-spacing: .3px; }
|
||||
.knob.outer {
|
||||
width: 50px; height: 50px; border-radius: 50%; display: flex; align-items: center; justify-content: center; position: relative;
|
||||
width: 60px; height: 60px; border-radius: 50%; display: flex; align-items: center; justify-content: center; position: relative;
|
||||
background: radial-gradient(circle at 35% 30%, #55585d, #2a2c2f 70%); box-shadow: 0 2px 5px #000, inset 0 1px 0 #6a6d72;
|
||||
}
|
||||
.knob-wrap.big .knob.outer { width: 58px; height: 58px; }
|
||||
.knob-wrap.big .knob.outer { width: 68px; height: 68px; }
|
||||
.knob.inner { width: 26px; height: 26px; border-radius: 50%; background: radial-gradient(circle at 35% 30%, #44474b, #1c1e20); box-shadow: inset 0 1px 0 #5a5d61; }
|
||||
.knob.joy .joy-cross { position: absolute; color: #6a6d72; font-size: 22px; font-weight: 700; pointer-events: none; }
|
||||
/* RANGE knob: white surround ring + zoom −/+ and curved arrows (like the GDU) */
|
||||
.knob.joy { overflow: visible; }
|
||||
.rng-ring { position: absolute; inset: -10px; border-radius: 50%; border: 2px solid #d2d7dc; pointer-events: none; }
|
||||
.rng-sign { position: absolute; top: 50%; transform: translateY(-50%); color: #d2d7dc; font-size: 17px; font-weight: 700; pointer-events: none; line-height: 1; }
|
||||
.rng-sign.m { left: -23px; } .rng-sign.p { right: -23px; }
|
||||
.rng-arc { position: absolute; top: -14px; color: #d2d7dc; font-size: 15px; pointer-events: none; }
|
||||
.rng-arc.l { left: -10px; } .rng-arc.r { right: -10px; }
|
||||
.knob.outer { cursor: pointer; border: none; padding: 0; }
|
||||
.knob.outer:active { box-shadow: 0 1px 2px #000, inset 0 2px 4px #000; }
|
||||
|
||||
@@ -278,6 +467,47 @@ body {
|
||||
.knob-arrow:active { background: #000; }
|
||||
.knob-arrow.left { left: -2px; } .knob-arrow.right { right: -2px; }
|
||||
.knob-arrow.top { top: -2px; } .knob-arrow.bottom { bottom: -2px; }
|
||||
.knob-cluster.zones { padding: 5px; }
|
||||
/* settings panel */
|
||||
.set-lbl { color: var(--c-mut); font-size: 12px; font-weight: 700; letter-spacing: .5px; margin-bottom: 8px; font-family: var(--ui-font); }
|
||||
.set-opt { display: flex; gap: 8px; }
|
||||
.set-opt .fbtn { flex: 1; }
|
||||
.set-hint { color: var(--c-mut); font-size: 11px; margin-top: 10px; line-height: 1.45; font-family: var(--ui-font); }
|
||||
/* radio tuner — KAP-140 green LCD + macOS-dark launcher chrome */
|
||||
.rtuner { width: min(400px, 94vw); background: linear-gradient(#23262c, #15171b); border: 1px solid #0a0a0a;
|
||||
border-top: 1px solid #4a4d52; border-radius: 16px; padding: 16px; font-family: var(--ui-font);
|
||||
box-shadow: 0 18px 50px rgba(0,0,0,.6), inset 0 1px 0 rgba(255,255,255,.05); }
|
||||
.rt-head { display: flex; align-items: center; gap: 10px; margin-bottom: 14px; }
|
||||
.rt-title { color: var(--c-txt); font-size: 18px; font-weight: 700; letter-spacing: .5px; }
|
||||
.rt-kind { color: #cfd6dd; background: #0f0f0f; border: 1px solid #3a3a3a; font-size: 10px; font-weight: 800; letter-spacing: 1px; padding: 2px 8px; border-radius: 999px; }
|
||||
/* green LCD with both frequencies */
|
||||
.rt-lcd { display: flex; align-items: center; justify-content: space-between; gap: 10px;
|
||||
background: #06160b; border: 1px solid #0a4d24; border-radius: 10px; padding: 12px 14px; margin-bottom: 16px;
|
||||
box-shadow: inset 0 0 22px rgba(0,90,35,.5); }
|
||||
.rt-f { display: flex; flex-direction: column; gap: 3px; }
|
||||
.rt-f.right { align-items: flex-end; }
|
||||
.rt-f span { color: #1f9d52; font-size: 10px; letter-spacing: 1.5px; }
|
||||
.rt-f b { font-family: 'Saira Condensed', monospace; font-size: 30px; font-weight: 700; line-height: 1; }
|
||||
.rt-f b.act { color: #3bff6e; text-shadow: 0 0 12px rgba(59,255,110,.55); }
|
||||
.rt-f b.sby { color: #f0f0f0; text-shadow: 0 0 8px rgba(240,240,240,.3); }
|
||||
.rt-swap { flex: 0 0 auto; width: 48px; height: 40px; background: linear-gradient(#2b2b2b, #161616); color: #e0e0e0;
|
||||
border: 1px solid #08090b; border-top: 1px solid #4a4a4a; border-radius: 9px; font-size: 22px; cursor: pointer;
|
||||
box-shadow: 0 2px 4px rgba(0,0,0,.5), inset 0 1px 0 rgba(255,255,255,.08); }
|
||||
.rt-swap:hover { color: #fff; } .rt-swap:active { transform: translateY(1px); background: #3a3a3a; color: #fff; }
|
||||
.rt-tune { display: flex; flex-direction: column; gap: 10px; }
|
||||
.rt-row { display: grid; grid-template-columns: 64px 1fr 1fr; gap: 10px; align-items: center; }
|
||||
.rt-unit { color: var(--c-txt2); font-size: 13px; font-weight: 700; letter-spacing: .5px; text-align: center;
|
||||
background: #2c2f35; border: 1px solid #3a3f47; border-radius: 8px; padding: 8px 0; }
|
||||
.rt-step { background: linear-gradient(#3b3e44, #23262b); color: #eef2f6; border: 1px solid #08090b; border-top: 1px solid #5c6168;
|
||||
border-radius: 10px; padding: 16px 0; font-size: 24px; font-weight: 700; cursor: pointer;
|
||||
box-shadow: 0 2px 5px rgba(0,0,0,.55), inset 0 1px 0 rgba(255,255,255,.1); }
|
||||
.rt-step:hover { background: linear-gradient(#454545, #2a2a2a); }
|
||||
.rt-step:active { transform: translateY(1px); background: linear-gradient(#3a3a3a, #1f1f1f); color: #fff; box-shadow: inset 0 2px 5px rgba(0,0,0,.6); }
|
||||
.rt-actions { display: flex; gap: 10px; margin-top: 16px; }
|
||||
.rt-btn { flex: 1; background: #232323; color: var(--c-txt2); border: 1px solid #3a3a3a; border-radius: 10px; padding: 13px; font-family: var(--ui-font); font-size: 14px; font-weight: 700; cursor: pointer; }
|
||||
.rt-btn:hover { background: #2e2e2e; }
|
||||
.rt-btn.primary { background: #f0f0f0; color: #0f0f0f; border-color: transparent; }
|
||||
.rt-btn.primary:active { transform: translateY(1px); filter: brightness(.92); }
|
||||
|
||||
.pan-pad { display: grid; grid-template-columns: repeat(2, 14px); gap: 2px; margin-top: 3px; }
|
||||
.pan-pad button {
|
||||
@@ -325,6 +555,10 @@ body {
|
||||
font: 600 12px/1 monospace; color: #0ff; background: #000a; padding: 4px 6px; }
|
||||
.mc-mode em { width: 8px; height: 8px; border: 1px solid #0ff; display: inline-block; }
|
||||
.mc-mode em.on { background: #0ff; }
|
||||
.mc-wind { position: absolute; left: 6px; top: 6px; display: flex; gap: 6px; align-items: center;
|
||||
font: 600 12px/1 monospace; color: #fff; background: #000a; padding: 4px 7px; }
|
||||
.mc-wind i { color: #9ab; font-style: normal; }
|
||||
.mc-windarr { display: inline-block; font-size: 16px; line-height: 1; color: #cfe3ff; }
|
||||
|
||||
/* Autopilot — GMC-710-style AFCS mode controller (app chrome look) */
|
||||
.afcs { width: 100%; height: 100%; display: flex; align-items: center; justify-content: center;
|
||||
@@ -482,10 +716,28 @@ body {
|
||||
background: #08240f; color: #29f06a; border: 1px solid #0a5; border-radius: 8px;
|
||||
padding: 12px 16px; font-weight: 800; font-family: monospace; letter-spacing: 1px;
|
||||
}
|
||||
.fbtn.add { background: #0a5; color: #021008; }
|
||||
.fbtn.add { background: #f0f0f0; color: #0f0f0f; }
|
||||
.fbtn.export { background: #ffae42; color: #2a1500; border-color: #ffae42; flex: 1; }
|
||||
.fbtn:disabled { opacity: .4; }
|
||||
.fms-actions { display: flex; gap: 8px; margin-top: 8px; }
|
||||
.fms-export { margin-top: 8px; font-size: 13px; padding: 8px; border-radius: 6px; }
|
||||
.fms-export.ok { background: #06330f; color: #9f9; }
|
||||
.fms-export.err { background: #330606; color: #f99; }
|
||||
|
||||
/* ---------------- Audio Panel (X1000) ---------------- */
|
||||
.audio-panel { width: 100%; height: 100%; display: flex; align-items: center; justify-content: center; background: var(--c-bg, #0f0f0f); }
|
||||
.apnl { width: min(420px, 94%); background: #15191e; border: 1px solid #2c343c; border-radius: 10px; padding: 14px 16px 18px; box-shadow: 0 8px 30px rgba(0,0,0,.5); font-family: var(--ui-font, 'Inter', system-ui); }
|
||||
.apnl-title { text-align: center; color: #36d2ff; font-weight: 700; letter-spacing: 2px; font-size: 14px; margin-bottom: 12px; }
|
||||
.apnl-grp { margin-bottom: 12px; }
|
||||
.apnl-h { color: #6f808d; font-size: 10px; letter-spacing: 1px; margin-bottom: 5px; }
|
||||
.apnl-row { display: grid; grid-template-columns: 1fr 1fr; gap: 8px; margin-bottom: 8px; }
|
||||
.apk { display: flex; flex-direction: column; align-items: center; gap: 2px; background: #1c2229; border: 1px solid #313a44; border-radius: 6px; color: #c9d3db; font: inherit; font-size: 12px; font-weight: 600; padding: 9px 6px; cursor: pointer; box-shadow: inset 0 1px 0 rgba(255,255,255,.04); }
|
||||
.apk:hover { background: #232a32; }
|
||||
.apk .apk-s { font-size: 9px; color: #6f808d; font-weight: 400; }
|
||||
.apk.on { border-color: #19b8e6; background: #0d2c38; color: #7fe0ff; box-shadow: 0 0 0 1px #19b8e6, 0 0 10px rgba(25,184,230,.25); }
|
||||
.apk.mic.on { border-color: #16c116; background: #0c2a0c; color: #7bf07b; box-shadow: 0 0 0 1px #16c116, 0 0 10px rgba(22,193,22,.25); }
|
||||
.apnl-vol { display: flex; align-items: center; gap: 8px; margin-top: 6px; color: #9fb0bd; font-size: 11px; }
|
||||
.apnl-vol input { flex: 1; accent-color: #19b8e6; }
|
||||
.apnl-vol b { color: #fff; font-size: 12px; min-width: 26px; text-align: right; }
|
||||
.apnl-backup { width: 100%; margin-top: 6px; background: #3a0d0d; border: 1px solid #b53333; border-radius: 8px; color: #ff8a8a; font: inherit; font-weight: 700; letter-spacing: 1px; font-size: 12px; padding: 11px; cursor: pointer; }
|
||||
.apnl-backup:hover { background: #5a1414; color: #ffb0b0; }
|
||||
|
||||
Reference in New Issue
Block a user