G1000: two-way sim sync, more PFD/MFD fidelity, authentic dialogs
Sync (FlyWithLua companions in plugins/ + server/fmssync.js): - FMS flight-plan two-way sync (App <-> in-sim FMS) via fms-sync.lua - G1000 UI-state publish (page/range/inset) via ui-sync.lua + CDI source, baro, map-range follow - Terrain awareness: elevation grid probe (terrain-probe.lua) -> red/yellow MFD overlay vs aircraft altitude PFD: - AFCS mode annunciation bar from autopilot _status datarefs - CDI source GPS/VLOC colouring, BRG1/BRG2 pointers + DME windows, marker beacons - magenta speed/altitude trend vectors, selected-altitude alerting - time-based (frame-rate-independent) smoothing for attitude/heading/tapes MFD: - nav data bar (DTK/ETE/active leg), airways overlay from earth_awy.dat, compass rose anchored to the ownship Dialogs (NEAREST/FLIGHTPLAN/DIRECT-TO/PROCEDURES): - flat, square, embedded G1000 look (no shadow/rounded/transparency) - compact lower-right placement, no close X (softkey toggles), single window - NEAREST 2-line entries (ILS/VFR, COM freq, runway length), PROC action menu Service worker: network-first HTML so reloads pick up new builds (cache v2). Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
@@ -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 path from 'node:path';
|
||||||
import { fileURLToPath } from 'node:url';
|
import { fileURLToPath } from 'node:url';
|
||||||
import { CONFIG, DATAREFS, WRITABLE_DATAREFS, COMMANDS } from './config.js';
|
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 { parseProcedures, procedureLegs as procLegs } from './procedures.js';
|
||||||
import * as fp from './flightplan.js';
|
import * as fp from './flightplan.js';
|
||||||
|
import { pushToSim, startFmsSync, startTerrainSync } from './fmssync.js';
|
||||||
|
|
||||||
const __dirname = path.dirname(fileURLToPath(import.meta.url));
|
const __dirname = path.dirname(fileURLToPath(import.meta.url));
|
||||||
// WEB_DIST can be overridden (e.g. the desktop app points it at the cockpit
|
// WEB_DIST can be overridden (e.g. the desktop app points it at the cockpit
|
||||||
@@ -46,7 +47,9 @@ function broadcast(obj) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function broadcastPlan() {
|
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) {
|
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_remove') { fp.removeWaypoint(msg.index); return broadcastPlan(); }
|
||||||
if (msg.type === 'fp_active') { fp.setActiveLeg(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_clear') { fp.setPlan({ waypoints: [] }); return broadcastPlan(); }
|
||||||
if (msg.type === 'fp_export') {
|
if (msg.type === 'fp_export') {
|
||||||
const r = fp.exportFms(msg.name || 'WEBFPL');
|
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,
|
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))
|
(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.
|
// Runways near a point — drawn in the PFD synthetic-vision view.
|
||||||
app.get('/api/nav/runways', (req, res) =>
|
app.get('/api/nav/runways', (req, res) =>
|
||||||
res.json(navRunways(+req.query.lat, +req.query.lon, +req.query.radius || 12))
|
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 });
|
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 });
|
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) =>
|
app.get('/api/nav/proc', (req, res) =>
|
||||||
res.json(procLegs(String(req.query.icao || ''), req.query.type, req.query.name, req.query.trans))
|
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,
|
heading: 87, slip: 0.3, gForce: 1.04, oat: 9,
|
||||||
apState: (1 << 0) | (1 << 1) | (1 << 14), // FD + HDG + ALT
|
apState: (1 << 0) | (1 << 1) | (1 << 14), // FD + HDG + ALT
|
||||||
apEngaged: 1, apHdgBug: 90, apAltBug: 6000, apVsBug: 500, apSpdBug: 120,
|
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,
|
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)
|
// radios (XP freq units: nav/com in 10 kHz, e.g. 11030 = 110.30)
|
||||||
nav1: 11030, nav1Sb: 11150, nav2: 11380, nav2Sb: 10890,
|
nav1: 11030, nav1Sb: 11150, nav2: 11380, nav2Sb: 10890,
|
||||||
com1: 12190, com1Sb: 13000, com2: 12475, com2Sb: 12180,
|
com1: 12190, com1Sb: 13000, com2: 12475, com2Sb: 12180,
|
||||||
// HSI / data fields
|
// HSI / data fields
|
||||||
obsCrs: 175, hsiDef: -0.6, hsiToFrom: 1, navBearing: 168, gsDef: 0.7,
|
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,
|
baro: 29.92, tas: 131, windSpd: 14, windDir: 240,
|
||||||
xpdrCode: 1200, xpdrMode: 2, fdPitch: 5, fdRoll: -10,
|
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)
|
// engine strip (arrays, like the sim)
|
||||||
engRpm: [2410], fuelFlow: [0.0072], oilTemp: [88], oilPress: [52], egt: [720],
|
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
|
// a sample plan so the map/FMS show something in demo mode
|
||||||
fp.setPlan({ name: 'DEMO', waypoints: [
|
fp.setPlan({ name: 'DEMO', waypoints: [
|
||||||
@@ -279,27 +299,57 @@ function startDemo() {
|
|||||||
{ id: 'SEA', lat: 47.435, lon: -122.310, type: 'VOR', alt: 4000 },
|
{ id: 'SEA', lat: 47.435, lon: -122.310, type: 'VOR', alt: 4000 },
|
||||||
{ id: 'KPDX', lat: 45.589, lon: -122.597, type: 'APT', alt: 1200 },
|
{ id: 'KPDX', lat: 45.589, lon: -122.597, type: 'APT', alt: 1200 },
|
||||||
]});
|
]});
|
||||||
|
pushToSim(fp.getPlan());
|
||||||
let t = 0;
|
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(() => {
|
setInterval(() => {
|
||||||
t += 0.1;
|
t += 0.1;
|
||||||
state.values.roll = -12 + Math.sin(t) * 4;
|
state.values.roll = -12 + Math.sin(t) * 4;
|
||||||
state.values.pitch = 4.5 + Math.cos(t * 0.7) * 1.5;
|
state.values.pitch = 4.5 + Math.cos(t * 0.7) * 1.5;
|
||||||
state.values.heading = (87 + Math.sin(t * 0.3) * 3 + 360) % 360;
|
const newAlt = 5500 + Math.sin(t * 0.5) * 120;
|
||||||
state.values.track = state.values.heading;
|
state.values.vspeed = (newAlt - state.values.altitude) / (0.1 / 60); // fpm from Δalt/Δt
|
||||||
state.values.altitude = 5500 + Math.sin(t * 0.5) * 40;
|
state.values.altitude = newAlt;
|
||||||
state.values.airspeed = 124 + Math.sin(t * 0.4) * 3;
|
state.values.airspeed = 124 + Math.sin(t * 0.4) * 8;
|
||||||
// creep south-east so the aircraft visibly moves on the map
|
// orbit so the aircraft visibly moves but stays near the demo flight plan
|
||||||
state.values.lat -= 0.0006;
|
const lat = lat0 + Math.cos(t * w) * R;
|
||||||
state.values.lon -= 0.0009;
|
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: 'status', xpConnected: true });
|
||||||
broadcast({ type: 'values', data: state.values });
|
broadcast({ type: 'values', data: state.values });
|
||||||
}, 100);
|
}, 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, () => {
|
server.listen(CONFIG.bridgePort, CONFIG.bridgeHost, () => {
|
||||||
log(`Bridge UI: http://${CONFIG.bridgeHost}:${CONFIG.bridgePort}`);
|
log(`Bridge UI: http://${CONFIG.bridgeHost}:${CONFIG.bridgePort}`);
|
||||||
log(`On tablets: http://<this-PC-LAN-IP>:${CONFIG.bridgePort}`);
|
log(`On tablets: http://<this-PC-LAN-IP>:${CONFIG.bridgePort}`);
|
||||||
loadNavData(); // async; FMS resolves idents once ready
|
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();
|
if (process.env.DEMO) startDemo();
|
||||||
else connectXPlane();
|
else connectXPlane();
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -60,6 +60,25 @@ export const DATAREFS = {
|
|||||||
hsiToFrom: 'sim/cockpit2/radios/indicators/hsi_flag_from_to_pilot',
|
hsiToFrom: 'sim/cockpit2/radios/indicators/hsi_flag_from_to_pilot',
|
||||||
navBearing: 'sim/cockpit2/radios/indicators/hsi_bearing_deg_mag_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 ---
|
// --- G1000 PFD: data fields ---
|
||||||
baro: 'sim/cockpit2/gauges/actuators/barometer_setting_in_hg_pilot',
|
baro: 'sim/cockpit2/gauges/actuators/barometer_setting_in_hg_pilot',
|
||||||
tas: 'sim/cockpit2/gauges/indicators/true_airspeed_kts_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',
|
apSpdBug: 'sim/cockpit2/autopilot/airspeed_dial_kts_mach',
|
||||||
apEngaged: 'sim/cockpit2/autopilot/servos_on',
|
apEngaged: 'sim/cockpit2/autopilot/servos_on',
|
||||||
navHdef: 'sim/cockpit2/radios/indicators/hsi_relative_bearing_vor_pilot',
|
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).
|
// Datarefs the frontend may WRITE (e.g. turning the heading bug knob).
|
||||||
@@ -121,6 +155,17 @@ export const COMMANDS = {
|
|||||||
xpdrIdent: 'sim/transponder/transponder_ident',
|
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
|
// 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
|
// 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').
|
// 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 content = lines.join('\n') + '\n';
|
||||||
|
|
||||||
const root = xplaneRoot();
|
const dir = fmsDir();
|
||||||
const dir = root ? path.join(root, 'Output', 'FMS plans') : path.join(process.cwd(), 'fms-out');
|
|
||||||
try {
|
try {
|
||||||
fs.mkdirSync(dir, { recursive: true });
|
fs.mkdirSync(dir, { recursive: true });
|
||||||
const file = path.join(dir, `${name}.fms`);
|
const file = path.join(dir, `${name}.fms`);
|
||||||
fs.writeFileSync(file, content);
|
fs.writeFileSync(file, content);
|
||||||
return { ok: true, file, intoXplane: !!root };
|
return { ok: true, file, intoXplane: !!xplaneRoot() };
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
return { ok: false, error: e.message };
|
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);
|
||||||
|
}
|
||||||
+77
-3
@@ -41,7 +41,10 @@ const airports = []; // { id, lat, lon, name, elev }
|
|||||||
const navaids = []; // { id, lat, lon, type:'VOR'|'NDB', freq, name }
|
const navaids = []; // { id, lat, lon, type:'VOR'|'NDB', freq, name }
|
||||||
const fixCells = new Map(); // "ilat,ilon" -> [{ id, lat, lon, type:'FIX' }]
|
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 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) {
|
||||||
if (!id || !isFinite(lat) || !isFinite(lon)) return;
|
if (!id || !isFinite(lat) || !isFinite(lon)) return;
|
||||||
@@ -90,6 +93,11 @@ async function parseNav(file) {
|
|||||||
if (!t || t === '99' || /^[IA]\b/.test(t) || /Version/.test(t)) continue;
|
if (!t || t === '99' || /^[IA]\b/.test(t) || /Version/.test(t)) continue;
|
||||||
const p = t.split(/\s+/);
|
const p = t.split(/\s+/);
|
||||||
const code = parseInt(p[0], 10);
|
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
|
if (code !== 2 && code !== 3) continue; // 2 = NDB, 3 = VOR/DME
|
||||||
const lat = parseFloat(p[1]), lon = parseFloat(p[2]), id = p[7];
|
const lat = parseFloat(p[1]), lon = parseFloat(p[2]), id = p[7];
|
||||||
const type = code === 2 ? 'NDB' : 'VOR';
|
const type = code === 2 ? 'NDB' : 'VOR';
|
||||||
@@ -128,9 +136,43 @@ async function parseAirports(file) {
|
|||||||
}
|
}
|
||||||
} else if (!placed && icao && (code === 101 || code === 102)) { // water/heli pad
|
} else if (!placed && icao && (code === 101 || code === 102)) { // water/heli pad
|
||||||
place(parseFloat(p[code === 101 ? 4 : 5]), parseFloat(p[code === 101 ? 5 : 6]));
|
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() {
|
export async function loadNavData() {
|
||||||
const root = findRoot();
|
const root = findRoot();
|
||||||
@@ -147,6 +189,10 @@ export async function loadNavData() {
|
|||||||
try {
|
try {
|
||||||
await parseFixes(pick('earth_fix.dat'));
|
await parseFixes(pick('earth_fix.dat'));
|
||||||
await parseNav(pick('earth_nav.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.
|
// 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'))
|
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)`); })
|
.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.
|
// NEAREST: closest airports (default) or navaids to a point, with range/bearing.
|
||||||
export function nearest(lat, lon, { count = 15, type = 'apt' } = {}) {
|
export function nearest(lat, lon, { count = 15, type = 'apt' } = {}) {
|
||||||
if (!isFinite(lat) || !isFinite(lon)) return [];
|
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
|
return src
|
||||||
.filter((f) => (type === 'vor' || type === 'ndb') ? f.type.toLowerCase() === type : true)
|
.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)) }))
|
.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)
|
.sort((a, b) => a.dist - b.dist)
|
||||||
.slice(0, count)
|
.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.
|
// 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;
|
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.
|
// Runways of every airport within radiusNm — for the PFD's synthetic-vision view.
|
||||||
export function runwaysNear(lat, lon, radiusNm = 12) {
|
export function runwaysNear(lat, lon, radiusNm = 12) {
|
||||||
if (!isFinite(lat) || !isFinite(lon)) return [];
|
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
|
// 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
|
// survives brief network blips. Live data (the bridge WebSocket, /api, and map
|
||||||
// tiles) is never cached — only same-origin GET app assets.
|
// 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());
|
self.addEventListener('install', () => self.skipWaiting());
|
||||||
|
|
||||||
@@ -18,7 +18,19 @@ self.addEventListener('fetch', (e) => {
|
|||||||
if (e.request.method !== 'GET' || url.origin !== location.origin) return;
|
if (e.request.method !== 'GET' || url.origin !== location.origin) return;
|
||||||
if (url.pathname.startsWith('/api') || url.pathname === '/ws') 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(
|
e.respondWith(
|
||||||
caches.open(CACHE).then(async (cache) => {
|
caches.open(CACHE).then(async (cache) => {
|
||||||
const cached = await cache.match(e.request);
|
const cached = await cache.match(e.request);
|
||||||
|
|||||||
+42
-17
@@ -1,4 +1,4 @@
|
|||||||
import React, { useState } from 'react';
|
import React, { useState, useEffect } from 'react';
|
||||||
import { useXplane } from './api/useXplane.js';
|
import { useXplane } from './api/useXplane.js';
|
||||||
import PFD from './components/PFD.jsx';
|
import PFD from './components/PFD.jsx';
|
||||||
import AutopilotPanel from './components/AutopilotPanel.jsx';
|
import AutopilotPanel from './components/AutopilotPanel.jsx';
|
||||||
@@ -9,6 +9,7 @@ import VFR from './components/VFR.jsx';
|
|||||||
import Bezel from './components/Bezel.jsx';
|
import Bezel from './components/Bezel.jsx';
|
||||||
import DirectTo from './components/DirectTo.jsx';
|
import DirectTo from './components/DirectTo.jsx';
|
||||||
import Proc from './components/Proc.jsx';
|
import Proc from './components/Proc.jsx';
|
||||||
|
import FplPage from './components/FplPage.jsx';
|
||||||
|
|
||||||
// Compact line icons for the nav rail (stroke = currentColor).
|
// Compact line icons for the nav rail (stroke = currentColor).
|
||||||
const ICONS = {
|
const ICONS = {
|
||||||
@@ -57,20 +58,42 @@ export default function App() {
|
|||||||
const [inset, setInset] = useState(false);
|
const [inset, setInset] = useState(false);
|
||||||
// INSET map options (base layer + declutter), set from the INSET submenu.
|
// INSET map options (base layer + declutter), set from the INSET submenu.
|
||||||
const [insetMode, setInsetMode] = useState({ base: 'topo', dcltr: 0 });
|
const [insetMode, setInsetMode] = useState({ base: 'topo', dcltr: 0 });
|
||||||
// The NRST (nearest airports/navaids) window, toggled by the PFD NRST softkey.
|
// Like the real G1000, only ONE window is open at a time. A single string
|
||||||
const [nrst, setNrst] = useState(false);
|
// holds the open one (nrst / tmr / dme / alerts / fpl / dto / proc); toggling
|
||||||
// The TMR/REF (timer / references) window, toggled by the PFD TMR/REF softkey.
|
// the same softkey closes it, opening another replaces it.
|
||||||
const [tmr, setTmr] = useState(false);
|
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), switched via the Map-Opt softkeys.
|
// MFD map mode (base layer), switched via the Map-Opt softkeys.
|
||||||
const [mapMode, setMapMode] = useState({ base: 'topo' });
|
const [mapMode, setMapMode] = useState({ base: 'topo' });
|
||||||
// Direct-To (D→) dialog — opened from the bezel on either GDU.
|
// MFD page group (MAP / FPL / NRST) — selected by the FMS knob, like the real G1000.
|
||||||
const [dto, setDto] = useState(false);
|
const MFD_PAGES = ['map', 'fpl', 'nrst'];
|
||||||
// PROC (procedures: SID/STAR/approach) dialog — opened from the bezel.
|
const [mfdPage, setMfdPage] = useState('map');
|
||||||
const [proc, setProc] = useState(false);
|
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 connKind = xp.xpConnected ? 'ok' : xp.connected ? 'warn' : 'bad';
|
||||||
const connText = xp.xpConnected ? 'X-PLANE' : xp.connected ? 'NO SIM' : 'OFFLINE';
|
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 (
|
return (
|
||||||
<div className={`app ${navWide ? 'nav-wide' : 'nav-narrow'}`}>
|
<div className={`app ${navWide ? 'nav-wide' : 'nav-narrow'}`}>
|
||||||
<aside className="sidebar">
|
<aside className="sidebar">
|
||||||
@@ -104,15 +127,19 @@ export default function App() {
|
|||||||
{tab === 'pfd' && (
|
{tab === 'pfd' && (
|
||||||
<Bezel variant="pfd" xp={xp} knobMode={knobMode} 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}
|
inset={inset} onSetInset={setInset} insetMode={insetMode} onInsetMode={setInsetMode}
|
||||||
nrst={nrst} onToggleNrst={() => setNrst((v) => !v)} onDirect={() => setDto(true)}
|
nrst={nrst} onToggleNrst={() => toggleWin('nrst')} onDirect={() => toggleWin('dto')}
|
||||||
tmr={tmr} onToggleTmr={() => setTmr((v) => !v)} onProc={() => setProc(true)}>
|
tmr={tmr} onToggleTmr={() => toggleWin('tmr')} dme={dme} onToggleDme={() => toggleWin('dme')}
|
||||||
<PFD values={xp.values} svt={svt3d} inset={inset} insetMode={insetMode} nrst={nrst} onCloseNrst={() => setNrst(false)}
|
alerts={alerts} onToggleAlerts={() => toggleWin('alerts')} onProc={() => toggleWin('proc')} onFpl={() => toggleWin('fpl')}>
|
||||||
tmr={tmr} onCloseTmr={() => setTmr(false)} flightPlan={xp.flightPlan} fp={xp.fp} />
|
<PFD values={xp.values} command={xp.command} 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)} flightPlan={xp.flightPlan} fp={xp.fp} />
|
||||||
|
{dialogs}
|
||||||
</Bezel>
|
</Bezel>
|
||||||
)}
|
)}
|
||||||
{tab === 'mfd' && (
|
{tab === 'mfd' && (
|
||||||
<Bezel variant="mfd" xp={xp} knobMode={knobMode} mapMode={mapMode} onMapMode={setMapMode} onDirect={() => setDto(true)} onProc={() => setProc(true)}>
|
<Bezel variant="mfd" xp={xp} knobMode={knobMode} mapMode={mapMode} onMapMode={setMapMode} onDirect={() => toggleWin('dto')} onProc={() => toggleWin('proc')} onFms={cycleMfd} onFpl={() => setMfdPage('fpl')}>
|
||||||
<MFD values={xp.values} flightPlan={xp.flightPlan} fp={xp.fp} mapMode={mapMode} />
|
<MFD values={xp.values} flightPlan={xp.flightPlan} fp={xp.fp} mapMode={mapMode} page={mfdPage} onCycle={cycleMfd} xp={xp} />
|
||||||
|
{dialogs}
|
||||||
</Bezel>
|
</Bezel>
|
||||||
)}
|
)}
|
||||||
{tab === 'map' && <MapView values={xp.values} flightPlan={xp.flightPlan} fp={xp.fp} />}
|
{tab === 'map' && <MapView values={xp.values} flightPlan={xp.flightPlan} fp={xp.fp} />}
|
||||||
@@ -120,8 +147,6 @@ export default function App() {
|
|||||||
{tab === 'vfr' && <VFR xp={xp} />}
|
{tab === 'vfr' && <VFR xp={xp} />}
|
||||||
{tab === 'ap' && <AutopilotPanel xp={xp} />}
|
{tab === 'ap' && <AutopilotPanel xp={xp} />}
|
||||||
</main>
|
</main>
|
||||||
{dto && <DirectTo xp={xp} onClose={() => setDto(false)} />}
|
|
||||||
{proc && <Proc xp={xp} onClose={() => setProc(false)} />}
|
|
||||||
{settings && (
|
{settings && (
|
||||||
<div className="dlg-backdrop" onClick={() => setSettings(false)}>
|
<div className="dlg-backdrop" onClick={() => setSettings(false)}>
|
||||||
<div className="dlg" onClick={(e) => e.stopPropagation()} style={{ minWidth: 360 }}>
|
<div className="dlg" onClick={(e) => e.stopPropagation()} style={{ minWidth: 360 }}>
|
||||||
|
|||||||
@@ -6,6 +6,7 @@ import { useEffect, useRef, useState, useCallback } from 'react';
|
|||||||
export function useXplane() {
|
export function useXplane() {
|
||||||
const [values, setValues] = useState({});
|
const [values, setValues] = useState({});
|
||||||
const [flightPlan, setFlightPlan] = useState({ name: 'ACTIVE', waypoints: [] });
|
const [flightPlan, setFlightPlan] = useState({ name: 'ACTIVE', waypoints: [] });
|
||||||
|
const [terrain, setTerrain] = useState(null); // elevation grid for terrain awareness
|
||||||
const [exportMsg, setExportMsg] = useState(null);
|
const [exportMsg, setExportMsg] = useState(null);
|
||||||
const [connected, setConnected] = useState(false); // socket to bridge
|
const [connected, setConnected] = useState(false); // socket to bridge
|
||||||
const [xpConnected, setXpConnected] = useState(false); // bridge <-> X-Plane
|
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 === 'status') setXpConnected(!!msg.xpConnected);
|
||||||
else if (msg.type === 'flightplan') setFlightPlan(msg.data);
|
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);
|
else if (msg.type === 'fp_export_result') setExportMsg(msg);
|
||||||
};
|
};
|
||||||
ws.onclose = () => {
|
ws.onclose = () => {
|
||||||
@@ -68,9 +70,16 @@ export function useXplane() {
|
|||||||
clear: () => send({ type: 'fp_clear' }),
|
clear: () => send({ type: 'fp_clear' }),
|
||||||
set: (plan) => send({ type: 'fp_set', plan }),
|
set: (plan) => send({ type: 'fp_set', plan }),
|
||||||
export: (name) => send({ type: 'fp_export', name }),
|
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.
|
// 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.
|
// Convenience: read a numeric value with a fallback.
|
||||||
export const num = (v, d = 0) => (typeof v === 'number' && isFinite(v) ? v : d);
|
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;
|
||||||
|
}
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import React, { useState } from 'react';
|
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):
|
// 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,
|
// title bar, knob columns, the 12 softkeys along the bottom — and, on the MFD,
|
||||||
@@ -26,25 +26,30 @@ const PFD_MENU = {
|
|||||||
// page; TOPO/TERRAIN/OSM switch the base map; BACK returns. (OSM is our tuned
|
// page; TOPO/TERRAIN/OSM switch the base map; BACK returns. (OSM is our tuned
|
||||||
// extra layer in an otherwise-empty slot.)
|
// extra layer in an otherwise-empty slot.)
|
||||||
const MFD_MENU = {
|
const MFD_MENU = {
|
||||||
root: ['SYSTEM', 'MAP', '', '', '', '', '', '', '', 'DCLTR', '', ''],
|
root: ['ENGINE', 'MAP', '', '', '', '', '', '', '', 'DCLTR', '', ''],
|
||||||
mapopt: ['TRAFFIC', 'PROFILE', 'TOPO', 'TERRAIN', 'AIRWAYS', '', 'NEXRAD', 'OSM', '', '', 'BACK', ''],
|
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)
|
// 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 };
|
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, knobMode = 'arrows', 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, onFms, mapMode, onMapMode, knobMode = 'arrows', children }) {
|
||||||
const u = variant === 'mfd' ? 'mfd' : 'pfd'; // command prefix
|
const u = variant === 'mfd' ? 'mfd' : 'pfd'; // command prefix
|
||||||
const fire = (suffix) => xp && xp.command(`${u}_${suffix}`);
|
const fire = (suffix) => xp && xp.command(`${u}_${suffix}`);
|
||||||
const [page, setPage] = useState('root'); // softkey menu page
|
const [page, setPage] = useState('root'); // softkey menu page
|
||||||
const [squawk, setSquawk] = useState(''); // XPDR code being typed
|
const [squawk, setSquawk] = useState(''); // XPDR code being typed
|
||||||
|
const [obs, setObs] = useState(false); // OBS (suspend) mode
|
||||||
|
|
||||||
const menu = variant === 'mfd' ? MFD_MENU : PFD_MENU;
|
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 setBase = (b) => onMapMode && onMapMode((m) => ({ ...m, base: m.base === b ? 'dark' : b }));
|
||||||
const xpdrMode = num(xp?.values?.xpdrMode);
|
const xpdrMode = num(xp?.values?.xpdrMode);
|
||||||
const setMode = (m) => xp && xp.setDataref('xpdrMode', m);
|
const setMode = (m) => xp && xp.setDataref('xpdrMode', m);
|
||||||
|
const hasAlerts = systemAlerts(xp?.values).length > 0; // lights the CAUTION key
|
||||||
|
|
||||||
const typeDigit = (d) => {
|
const typeDigit = (d) => {
|
||||||
const next = (squawk + d).slice(-4);
|
const next = (squawk + d).slice(-4);
|
||||||
@@ -59,12 +64,13 @@ export default function Bezel({ variant = 'pfd', xp, svt3d, onToggleSvt, inset,
|
|||||||
fire(`softkey${i + 1}`); // mirror to the in-sim G1000
|
fire(`softkey${i + 1}`); // mirror to the in-sim G1000
|
||||||
if (variant === 'mfd') {
|
if (variant === 'mfd') {
|
||||||
if (label === 'MAP') setPage('mapopt');
|
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 === 'BACK') setPage('root');
|
||||||
else if (label === 'TOPO') setBase('topo');
|
else if (label === 'TOPO') setBase('topo');
|
||||||
else if (label === 'TERRAIN') setBase('terrain');
|
else if (label === 'TERRAIN') setBase('terrain');
|
||||||
else if (label === 'OSM') setBase('osm');
|
else if (label === 'OSM') setBase('osm');
|
||||||
else if (label === 'DCLTR') onMapMode && onMapMode((m) => ({ ...m, dcltr: m.dcltr ? 0 : 1 }));
|
else if (label === 'DCLTR') onMapMode && onMapMode((m) => ({ ...m, dcltr: m.dcltr ? 0 : 1 }));
|
||||||
|
else if (label === 'AIRWAYS') onMapMode && onMapMode((m) => ({ ...m, airways: !m.airways }));
|
||||||
} else {
|
} else {
|
||||||
if (label === 'PFD') setPage('pfd');
|
if (label === 'PFD') setPage('pfd');
|
||||||
else if (label === 'BACK') setPage(page === 'xpdrcode' ? 'xpdr' : 'root');
|
else if (label === 'BACK') setPage(page === 'xpdrcode' ? 'xpdr' : 'root');
|
||||||
@@ -79,6 +85,9 @@ export default function Bezel({ variant = 'pfd', xp, svt3d, onToggleSvt, inset,
|
|||||||
else if (label === 'TERRAIN') onInsetMode && onInsetMode((m) => ({ ...m, base: 'terrain' }));
|
else if (label === 'TERRAIN') onInsetMode && onInsetMode((m) => ({ ...m, base: 'terrain' }));
|
||||||
else if (label === 'NRST') onToggleNrst && onToggleNrst();
|
else if (label === 'NRST') onToggleNrst && onToggleNrst();
|
||||||
else if (label === 'TMR/REF') onToggleTmr && onToggleTmr();
|
else if (label === 'TMR/REF') onToggleTmr && onToggleTmr();
|
||||||
|
else if (label === 'DME') onToggleDme && onToggleDme();
|
||||||
|
else if (label === 'OBS') setObs((v) => !v); // suspend / OBS mode (also fires the sim softkey above)
|
||||||
|
else if (label === 'CAUTION') onToggleAlerts && onToggleAlerts();
|
||||||
else if (label === 'XPDR') setPage('xpdr');
|
else if (label === 'XPDR') setPage('xpdr');
|
||||||
else if (label === 'STBY') setMode(1);
|
else if (label === 'STBY') setMode(1);
|
||||||
else if (label === 'ON') setMode(2);
|
else if (label === 'ON') setMode(2);
|
||||||
@@ -94,8 +103,9 @@ export default function Bezel({ variant = 'pfd', xp, svt3d, onToggleSvt, inset,
|
|||||||
const isOn = (label) => {
|
const isOn = (label) => {
|
||||||
if (variant === 'mfd') return (label === 'TOPO' && mapMode?.base === 'topo')
|
if (variant === 'mfd') return (label === 'TOPO' && mapMode?.base === 'topo')
|
||||||
|| (label === 'TERRAIN' && mapMode?.base === 'terrain') || (label === 'OSM' && mapMode?.base === 'osm')
|
|| (label === 'TERRAIN' && mapMode?.base === 'terrain') || (label === 'OSM' && mapMode?.base === 'osm')
|
||||||
|| (label === 'DCLTR' && mapMode?.dcltr > 0);
|
|| (label === 'DCLTR' && mapMode?.dcltr > 0) || (label === 'AIRWAYS' && mapMode?.airways);
|
||||||
return (label === 'SYN TERR' && svt3d) || (label === 'INSET' && inset) || (label === 'NRST' && nrst) || (label === 'TMR/REF' && tmr)
|
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 === 'STBY' && xpdrMode === 1) || (label === 'ON' && xpdrMode === 2) || (label === 'ALT' && xpdrMode === 3)
|
||||||
|| (page === 'inset' && label === 'TOPO' && insetMode?.base === 'topo')
|
|| (page === 'inset' && label === 'TOPO' && insetMode?.base === 'topo')
|
||||||
|| (page === 'inset' && label === 'TERRAIN' && insetMode?.base === 'terrain')
|
|| (page === 'inset' && label === 'TERRAIN' && insetMode?.base === 'terrain')
|
||||||
@@ -106,7 +116,8 @@ export default function Bezel({ variant = 'pfd', xp, svt3d, onToggleSvt, inset,
|
|||||||
<div className="bezel">
|
<div className="bezel">
|
||||||
<div className="bezel-knobs left">
|
<div className="bezel-knobs left">
|
||||||
<Knob label="NAV" sub="VOL · PUSH ID" fire={fire} mode={knobMode}
|
<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" />
|
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}
|
<Knob label="HDG" sub="PUSH HDG SYNC" fire={fire} mode={knobMode}
|
||||||
outer={['hdg_up', 'hdg_down']} push="hdg_sync" />
|
outer={['hdg_up', 'hdg_down']} push="hdg_sync" />
|
||||||
{variant === 'mfd' && xp && <APController xp={xp} />}
|
{variant === 'mfd' && xp && <APController xp={xp} />}
|
||||||
@@ -116,36 +127,43 @@ export default function Bezel({ variant = 'pfd', xp, svt3d, onToggleSvt, inset,
|
|||||||
|
|
||||||
<div className="bezel-core">
|
<div className="bezel-core">
|
||||||
<div className="bezel-title">XPLANE 1000</div>
|
<div className="bezel-title">XPLANE 1000</div>
|
||||||
<div className="bezel-screen">{children}</div>
|
<div className="bezel-screen">
|
||||||
|
<div className="screen-content">{children}</div>
|
||||||
{page === 'xpdrcode' && (
|
{page === 'xpdrcode' && (
|
||||||
<div className="squawk-entry">SQUAWK <b>{squawk.padEnd(4, '_')}</b></div>
|
<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}</span>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{/* physical bezel keys — blank, aligned under the on-screen labels */}
|
||||||
<div className="softkeys">
|
<div className="softkeys">
|
||||||
{keys.map((s, i) => (
|
{keys.map((s, i) => (
|
||||||
<button
|
<button key={i} disabled={!s} onClick={() => onSoftkey(i, s)}
|
||||||
key={i}
|
className={`softkey ${s ? '' : 'empty'}`} aria-label={s || undefined} />
|
||||||
disabled={!s}
|
|
||||||
onClick={() => onSoftkey(i, s)}
|
|
||||||
className={`softkey ${s ? '' : 'empty'} ${s === 'CAUTION' ? 'caution' : ''} ${isOn(s) ? 'on' : ''}`}
|
|
||||||
>{s}</button>
|
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="bezel-knobs right">
|
<div className="bezel-knobs right">
|
||||||
<Knob label="COM" sub="VOL · PUSH SQ" fire={fire} mode={knobMode}
|
<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" />
|
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}
|
<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" />
|
outer={['crs_up', 'crs_down']} inner={['baro_up', 'baro_down']} push="crs_sync" />
|
||||||
<Knob label="RANGE" sub="PUSH PAN" joy fire={fire} mode={knobMode}
|
<Knob label="RANGE" sub="PUSH PAN" joy fire={fire} mode={knobMode}
|
||||||
outer={['range_up', 'range_down']} push="pan_push" pan />
|
outer={['range_up', 'range_down']} push="pan_push" pan />
|
||||||
<div className="bezel-grid">
|
<div className="bezel-grid">
|
||||||
<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="direct" onClick={onDirect}>D→</BtnG><BtnG fire={fire} mode={knobMode} cmd="menu">MENU</BtnG>
|
||||||
<BtnG fire={fire} mode={knobMode} cmd="fpl">FPL</BtnG><BtnG fire={fire} mode={knobMode} cmd="proc" onClick={onProc}>PROC</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">CLR</BtnG><BtnG fire={fire} mode={knobMode} cmd="ent">ENT</BtnG>
|
<BtnG fire={fire} mode={knobMode} cmd="clr">CLR</BtnG><BtnG fire={fire} mode={knobMode} cmd="ent">ENT</BtnG>
|
||||||
</div>
|
</div>
|
||||||
<Knob label="FMS" sub="PUSH CRSR" big fire={fire} mode={knobMode}
|
<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" />
|
outer={['fms_outer_up', 'fms_outer_down']} inner={['fms_inner_up', 'fms_inner_down']} push="cursor"
|
||||||
|
onTurn={onFms} />
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
@@ -186,12 +204,14 @@ function APController({ xp }) {
|
|||||||
// the mouse wheel; the inner ring via the top/bottom arrows (˄ ˅) and shift+wheel.
|
// 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
|
// Clicking the knob centre fires the push action (PUSH …). The RANGE knob also
|
||||||
// pans with a directional cross.
|
// pans with a directional cross.
|
||||||
function Knob({ label, sub, outer, inner, push, big, joy, pan, fire, mode = 'arrows' }) {
|
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) => {
|
const onWheel = (e) => {
|
||||||
if (!outer) return;
|
if (!outer) return;
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
const set = (e.shiftKey && inner) ? inner : outer;
|
if (e.shiftKey && inner) fire(e.deltaY < 0 ? inner[0] : inner[1]);
|
||||||
fire(e.deltaY < 0 ? set[0] : set[1]);
|
else outerStep(e.deltaY < 0 ? 1 : -1);
|
||||||
};
|
};
|
||||||
const zoneClick = (e) => {
|
const zoneClick = (e) => {
|
||||||
const r = e.currentTarget.getBoundingClientRect();
|
const r = e.currentTarget.getBoundingClientRect();
|
||||||
@@ -199,19 +219,21 @@ function Knob({ label, sub, outer, inner, push, big, joy, pan, fire, mode = 'arr
|
|||||||
const dy = e.clientY - (r.top + r.height / 2);
|
const dy = e.clientY - (r.top + r.height / 2);
|
||||||
const rel = Math.hypot(dx, dy) / (r.width / 2);
|
const rel = Math.hypot(dx, dy) / (r.width / 2);
|
||||||
if (rel < 0.42 && push) { fire(push); return; } // centre → PUSH
|
if (rel < 0.42 && push) { fire(push); return; } // centre → PUSH
|
||||||
if (Math.abs(dy) >= Math.abs(dx)) { if (outer) fire(dy < 0 ? outer[0] : outer[1]); }
|
if (Math.abs(dy) >= Math.abs(dx)) outerStep(dy < 0 ? 1 : -1);
|
||||||
else if (inner) fire(dx > 0 ? inner[0] : inner[1]);
|
else if (inner) fire(dx > 0 ? inner[0] : inner[1]);
|
||||||
else if (outer) fire(dx > 0 ? outer[0] : outer[1]);
|
else outerStep(dx > 0 ? 1 : -1);
|
||||||
};
|
};
|
||||||
const zones = mode === 'zones';
|
const zones = mode === 'zones';
|
||||||
return (
|
return (
|
||||||
<div className={`knob-wrap ${big ? 'big' : ''}`}>
|
<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>
|
<span className="knob-lbl">{label}</span>
|
||||||
<div className={`knob-cluster ${zones ? 'zones' : ''}`}>
|
<div className={`knob-cluster ${zones ? 'zones' : ''}`}>
|
||||||
{/* arrows mode (touch-friendly): visible ˄‹›˅ buttons. zones mode: click
|
{/* arrows mode (touch-friendly): visible ˄‹›˅ buttons. zones mode: click
|
||||||
the knob face itself (top/bottom = outer, left/right = inner). */}
|
the knob face itself (top/bottom = outer, left/right = inner). */}
|
||||||
{!zones && inner && <button className="knob-arrow top" onClick={() => fire(inner[0])}>˄</button>}
|
{!zones && inner && <button className="knob-arrow top" onClick={() => fire(inner[0])}>˄</button>}
|
||||||
{!zones && outer && <button className="knob-arrow left" onClick={() => fire(outer[1])}>‹</button>}
|
{!zones && outer && <button className="knob-arrow left" onClick={() => outerStep(-1)}>‹</button>}
|
||||||
<button
|
<button
|
||||||
className={`knob outer ${joy ? 'joy' : ''}`}
|
className={`knob outer ${joy ? 'joy' : ''}`}
|
||||||
onWheel={onWheel}
|
onWheel={onWheel}
|
||||||
@@ -219,9 +241,15 @@ function Knob({ label, sub, outer, inner, push, big, joy, pan, fire, mode = 'arr
|
|||||||
title={zones ? `${outer ? 'oben/unten' : ''}${inner ? ' · links/rechts (fein)' : ''}${push ? ' · Mitte: PUSH' : ''}` : (push ? 'PUSH' : '')}
|
title={zones ? `${outer ? 'oben/unten' : ''}${inner ? ' · links/rechts (fein)' : ''}${push ? ' · Mitte: PUSH' : ''}` : (push ? 'PUSH' : '')}
|
||||||
>
|
>
|
||||||
<span className="knob inner" />
|
<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>
|
</button>
|
||||||
{!zones && outer && <button className="knob-arrow right" onClick={() => fire(outer[0])}>›</button>}
|
{!zones && outer && <button className="knob-arrow right" onClick={() => outerStep(1)}>›</button>}
|
||||||
{!zones && inner && <button className="knob-arrow bottom" onClick={() => fire(inner[1])}>˅</button>}
|
{!zones && inner && <button className="knob-arrow bottom" onClick={() => fire(inner[1])}>˅</button>}
|
||||||
</div>
|
</div>
|
||||||
{pan && (
|
{pan && (
|
||||||
|
|||||||
@@ -49,11 +49,10 @@ export default function DirectTo({ xp, onClose }) {
|
|||||||
};
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="dlg-backdrop" onClick={onClose}>
|
<div className="gwin-backdrop" onClick={onClose}>
|
||||||
<div className="dlg dto" onClick={(e) => e.stopPropagation()}>
|
<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">
|
<div className="dto-body">
|
||||||
<label className="dto-lbl">WAYPOINT</label>
|
|
||||||
<input
|
<input
|
||||||
ref={inputRef}
|
ref={inputRef}
|
||||||
className="dto-input"
|
className="dto-input"
|
||||||
@@ -74,11 +73,16 @@ export default function DirectTo({ xp, onClose }) {
|
|||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
{sel && (
|
{sel && (
|
||||||
<div className="dto-sel">
|
<>
|
||||||
<span className="dto-id">{sel.id}</span>
|
<div className="dto-tgt"><span className="dto-id">{sel.id}</span><span className="dto-type">{sel.type}</span></div>
|
||||||
<span className="dto-type">{sel.type}</span>
|
<div className="dto-grid">
|
||||||
{preview && <span className="dto-vec">{String(Math.round(preview.brg)).padStart(3, '0')}° · {preview.dist.toFixed(1)} NM</span>}
|
<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>
|
||||||
|
</>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
<div className="dlg-actions">
|
<div className="dlg-actions">
|
||||||
|
|||||||
@@ -0,0 +1,132 @@
|
|||||||
|
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;
|
||||||
|
|
||||||
|
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 ? `${w.alt}` : '_____'}</span>
|
||||||
|
<button className="r-del" onClick={(e) => { e.stopPropagation(); fp.remove(i); }}>✕</button>
|
||||||
|
</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>
|
||||||
|
);
|
||||||
|
}
|
||||||
+67
-19
@@ -1,36 +1,79 @@
|
|||||||
import React, { useState } from 'react';
|
import React, { useState } from 'react';
|
||||||
import { num } from '../api/useXplane.js';
|
import { num } from '../api/useXplane.js';
|
||||||
import MapView from './MapView.jsx';
|
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 arr = (v, i = 0, d = 0) => (Array.isArray(v) ? num(v[i], d) : num(v, d));
|
||||||
const KG_PER_GAL = 2.72; // avgas
|
const KG_PER_GAL = 2.72; // avgas
|
||||||
const navF = (v) => (num(v) / 100).toFixed(2);
|
const navF = (v) => (num(v) / 100).toFixed(2);
|
||||||
const comF = (v) => (num(v) / 100).toFixed(3);
|
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)
|
// 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
|
// 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.
|
// 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 [rangeNm, setRangeNm] = useState(8);
|
||||||
|
const idx = Math.max(0, MFD_PAGES.findIndex((p) => p.id === page));
|
||||||
return (
|
return (
|
||||||
<div className="mfd-g1000">
|
<div className="mfd-g1000">
|
||||||
<MfdTopBar V={V} />
|
<MfdTopBar V={V} fp={flightPlan} />
|
||||||
<div className="mfd-body">
|
<div className="mfd-body">
|
||||||
<EisStrip V={V} />
|
<EisStrip V={V} />
|
||||||
<div className="mfd-map">
|
<div className="mfd-map">
|
||||||
|
{/* MapView stays mounted (keeps tiles warm) but is hidden under NRST */}
|
||||||
|
<div style={{ position: 'absolute', inset: 0, visibility: page === 'map' ? 'visible' : 'hidden' }}>
|
||||||
<MapView values={V} flightPlan={flightPlan} fp={fp} hud={false}
|
<MapView values={V} flightPlan={flightPlan} fp={fp} hud={false}
|
||||||
mapMode={mapMode} dcltr={mapMode?.dcltr || 0} onView={({ rangeNm }) => setRangeNm(rangeNm)} />
|
mapMode={mapMode} dcltr={mapMode?.dcltr || 0} rangeNm={num(V.uiMapRange) || undefined}
|
||||||
|
terrain={xp?.terrain} rose onView={({ rangeNm }) => setRangeNm(rangeNm)} />
|
||||||
<MapChrome V={V} rangeNm={rangeNm} />
|
<MapChrome V={V} rangeNm={rangeNm} />
|
||||||
</div>
|
</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>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
/* ---------------- top NAV/COM bar ---------------- */
|
/* ---------------- top NAV/COM bar ---------------- */
|
||||||
function MfdTopBar({ V }) {
|
function MfdTopBar({ V, fp }) {
|
||||||
const gs = Math.round(num(V.groundspeed) * 1.94384);
|
const gs = Math.round(num(V.groundspeed) * 1.94384);
|
||||||
const trk = String(Math.round(num(V.track)) % 360).padStart(3, '0');
|
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>;
|
const swap = (x, y) => <text x={x} y={y} fill="#0ff" fontSize="16" textAnchor="middle">⇔</text>;
|
||||||
return (
|
return (
|
||||||
<svg className="mfd-topbar" viewBox="0 0 1000 70" preserveAspectRatio="none" fontFamily="monospace">
|
<svg className="mfd-topbar" viewBox="0 0 1000 70" preserveAspectRatio="none" fontFamily="monospace">
|
||||||
@@ -51,10 +94,22 @@ function MfdTopBar({ V }) {
|
|||||||
<text x="350" y="27" fill="#e040fb" fontSize="15" fontWeight="bold">{gs}</text>
|
<text x="350" y="27" fill="#e040fb" fontSize="15" fontWeight="bold">{gs}</text>
|
||||||
<text x="378" y="27" fill="#0c9" fontSize="11">KT</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="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="520" y="27" fill="#fff" fontSize="13">TRK</text>
|
||||||
<text x="560" y="27" fill="#e040fb" fontSize="15" fontWeight="bold">{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="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 */}
|
{/* COM1 / COM2 */}
|
||||||
<text x="690" y="27" fill="#0f0" fontSize="17">{comF(V.com1)}</text>
|
<text x="690" y="27" fill="#0f0" fontSize="17">{comF(V.com1)}</text>
|
||||||
{swap(818, 27)}
|
{swap(818, 27)}
|
||||||
@@ -191,24 +246,17 @@ function niceRange(nm) { let r = NICE[0]; for (const s of NICE) if (nm >= s) r =
|
|||||||
function MapChrome({ V, rangeNm }) {
|
function MapChrome({ V, rangeNm }) {
|
||||||
const gs = Math.round(num(V.groundspeed) * 1.94384);
|
const gs = Math.round(num(V.groundspeed) * 1.94384);
|
||||||
const rng = niceRange(rangeNm);
|
const rng = niceRange(rangeNm);
|
||||||
const cx = 160, cy = 160, r = 150;
|
const wd = ((Math.round(num(V.windDir)) % 360) + 360) % 360, ws = Math.round(num(V.windSpd));
|
||||||
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>);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return (
|
return (
|
||||||
<div className="map-chrome">
|
<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-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-range">{rng} NM</div>
|
||||||
<div className="mc-mode">NAV <em className="on" /><em /><em /><em /><em /></div>
|
|
||||||
</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" ' +
|
'<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>';
|
'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
|
// 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.
|
// VOR hexagon, brown NDB dot-ring, light fix triangle — with an optional label.
|
||||||
function navSymbol(f, label) {
|
function navSymbol(f, label) {
|
||||||
@@ -43,15 +57,20 @@ const TILES = {
|
|||||||
dark: null,
|
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 elRef = useRef(null);
|
||||||
const mapRef = useRef(null);
|
const mapRef = useRef(null);
|
||||||
const acRef = useRef(null);
|
const acRef = useRef(null);
|
||||||
|
const roseRef = useRef(null);
|
||||||
const routeRef = useRef(null);
|
const routeRef = useRef(null);
|
||||||
const wpLayerRef = useRef(null);
|
const wpLayerRef = useRef(null);
|
||||||
const navLayerRef = useRef(null);
|
const navLayerRef = useRef(null);
|
||||||
const navAbortRef = useRef(null);
|
const navAbortRef = useRef(null);
|
||||||
|
const awyLayerRef = useRef(null);
|
||||||
|
const awyOnRef = useRef(false);
|
||||||
|
const refreshAirwaysRef = useRef(null);
|
||||||
const baseRef = useRef(null);
|
const baseRef = useRef(null);
|
||||||
|
const terrRef = useRef(null);
|
||||||
const [follow, setFollow] = useState(true);
|
const [follow, setFollow] = useState(true);
|
||||||
const followRef = useRef(true);
|
const followRef = useRef(true);
|
||||||
followRef.current = follow;
|
followRef.current = follow;
|
||||||
@@ -61,8 +80,10 @@ export default function MapView({ values, flightPlan, fp, inset = false, hud = t
|
|||||||
const track = num(values.track);
|
const track = num(values.track);
|
||||||
const gs = num(values.groundspeed) * 1.94384; // m/s -> kt
|
const gs = num(values.groundspeed) * 1.94384; // m/s -> kt
|
||||||
const base = mapMode?.base || 'topo';
|
const base = mapMode?.base || 'topo';
|
||||||
|
const airways = !!mapMode?.airways;
|
||||||
const dcltrRef = useRef(dcltr);
|
const dcltrRef = useRef(dcltr);
|
||||||
dcltrRef.current = dcltr;
|
dcltrRef.current = dcltr;
|
||||||
|
awyOnRef.current = airways;
|
||||||
|
|
||||||
// Swap the base tile layer (and report it via the container's dark class).
|
// Swap the base tile layer (and report it via the container's dark class).
|
||||||
const applyBase = (map, name) => {
|
const applyBase = (map, name) => {
|
||||||
@@ -82,14 +103,49 @@ export default function MapView({ values, flightPlan, fp, inset = false, hud = t
|
|||||||
|
|
||||||
// create map once
|
// create map once
|
||||||
useEffect(() => {
|
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);
|
.setView([lat, lon], inset ? 10 : 9);
|
||||||
applyBase(map, base);
|
applyBase(map, base);
|
||||||
|
|
||||||
|
// 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
|
navLayerRef.current = L.layerGroup().addTo(map); // real airports/navaids/fixes
|
||||||
routeRef.current = L.layerGroup().addTo(map); // flight-plan legs (white + magenta active)
|
routeRef.current = L.layerGroup().addTo(map); // flight-plan legs (white + magenta active)
|
||||||
wpLayerRef.current = L.layerGroup().addTo(map);
|
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
|
// 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).
|
// vector symbology (cyan airports, green VOR hexagons, NDB dot-rings, fixes).
|
||||||
const refreshNav = async () => {
|
const refreshNav = async () => {
|
||||||
@@ -111,10 +167,17 @@ export default function MapView({ values, flightPlan, fp, inset = false, hud = t
|
|||||||
for (const f of feats) navSymbol(f, labels).addTo(layer);
|
for (const f of feats) navSymbol(f, labels).addTo(layer);
|
||||||
} catch { /* aborted or offline — leave as is */ }
|
} 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));
|
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] });
|
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);
|
acRef.current = L.marker([lat, lon], { icon, interactive: false, zIndexOffset: 1000 }).addTo(map);
|
||||||
|
|
||||||
@@ -134,6 +197,58 @@ export default function MapView({ values, flightPlan, fp, inset = false, hud = t
|
|||||||
if (map) applyBase(map, base);
|
if (map) applyBase(map, base);
|
||||||
}, [base]); // eslint-disable-line
|
}, [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 = base === '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, base]); // 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
|
// declutter: hide nav symbology, or repopulate it, when the level changes
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const map = mapRef.current;
|
const map = mapRef.current;
|
||||||
@@ -147,6 +262,7 @@ export default function MapView({ values, flightPlan, fp, inset = false, hud = t
|
|||||||
const ac = acRef.current, map = mapRef.current;
|
const ac = acRef.current, map = mapRef.current;
|
||||||
if (!ac || !map) return;
|
if (!ac || !map) return;
|
||||||
ac.setLatLng([lat, lon]);
|
ac.setLatLng([lat, lon]);
|
||||||
|
roseRef.current?.setLatLng([lat, lon]);
|
||||||
const el = ac.getElement()?.querySelector('svg');
|
const el = ac.getElement()?.querySelector('svg');
|
||||||
if (el) el.style.transform = `rotate(${track}deg)`;
|
if (el) el.style.transform = `rotate(${track}deg)`;
|
||||||
if (followRef.current) map.panTo([lat, lon], { animate: true, duration: 0.5 });
|
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);
|
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 [type, setType] = useState('apt');
|
||||||
const [rows, setRows] = useState([]);
|
const [rows, setRows] = useState([]);
|
||||||
const lastRef = useRef(null);
|
const lastRef = useRef(null);
|
||||||
@@ -42,32 +42,40 @@ export default function Nearest({ values, onClose }) {
|
|||||||
}, [type, Math.round(lat * 50), Math.round(lon * 50)]); // re-key on ~1nm moves
|
}, [type, Math.round(lat * 50), Math.round(lon * 50)]); // re-key on ~1nm moves
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="nrst-window">
|
<div className={`nrst-window ${full ? 'full' : ''}`}>
|
||||||
<div className="nrst-head">
|
<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">
|
<div className="nrst-tabs">
|
||||||
{TABS.map((t) => (
|
{TABS.map((t) => (
|
||||||
<button key={t.id} className={type === t.id ? 'on' : ''} onClick={() => setType(t.id)}>{t.label}</button>
|
<button key={t.id} className={type === t.id ? 'on' : ''} onClick={() => setType(t.id)}>{t.label}</button>
|
||||||
))}
|
))}
|
||||||
</div>
|
</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>
|
||||||
<div className="nrst-list">
|
<div className="nrst-list">
|
||||||
{rows.length === 0 && <div className="nrst-empty">— no data —</div>}
|
{rows.length === 0 && <div className="nrst-empty">— no data —</div>}
|
||||||
{rows.map((f, i) => (
|
{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}>
|
<div className="nrst-row" key={f.id + i}>
|
||||||
<span className="c-id">{f.id}</span>
|
<span className="c-id">{f.id}</span>
|
||||||
<span className="c-brg">{String(num(f.brg)).padStart(3, '0')}°</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-dis">{num(f.dist).toFixed(1)}<u>nm</u></span>
|
||||||
<span className="c-xtra">
|
<span className="c-xtra">{freqStr(f.freq, type)}</span>
|
||||||
{type === 'apt' ? `${Math.round(num(f.elev))}ft` : freqStr(f.freq, type)}
|
|
||||||
</span>
|
|
||||||
{f.name && <span className="c-name">{f.name}</span>}
|
{f.name && <span className="c-name">{f.name}</span>}
|
||||||
</div>
|
</div>
|
||||||
))}
|
))}
|
||||||
|
|||||||
+339
-47
@@ -1,8 +1,9 @@
|
|||||||
import React, { useRef, useState, useEffect, useLayoutEffect, Suspense, lazy } from 'react';
|
import React, { useRef, useState, useEffect, useLayoutEffect, Suspense, lazy } from 'react';
|
||||||
import { num } from '../api/useXplane.js';
|
import { num, systemAlerts } from '../api/useXplane.js';
|
||||||
import MapView from './MapView.jsx';
|
import MapView from './MapView.jsx';
|
||||||
import Nearest from './Nearest.jsx';
|
import Nearest from './Nearest.jsx';
|
||||||
import TimerRef from './TimerRef.jsx';
|
import TimerRef from './TimerRef.jsx';
|
||||||
|
import RadioTuner from './RadioTuner.jsx';
|
||||||
// Lazy-load the heavy WebGL terrain engine only when the PFD is shown.
|
// Lazy-load the heavy WebGL terrain engine only when the PFD is shown.
|
||||||
const SVT = lazy(() => import('./SVT.jsx'));
|
const SVT = lazy(() => import('./SVT.jsx'));
|
||||||
|
|
||||||
@@ -87,7 +88,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).
|
// 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 };
|
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, svt = true, inset = false, insetMode, nrst = false, onCloseNrst, tmr = false, onCloseTmr, dme = false, onCloseDme, alerts = false, onCloseAlerts, flightPlan, fp }) {
|
||||||
const wrapRef = useRef(null);
|
const wrapRef = useRef(null);
|
||||||
const svgRef = useRef(null);
|
const svgRef = useRef(null);
|
||||||
const [box, setBox] = useState(null);
|
const [box, setBox] = useState(null);
|
||||||
@@ -116,6 +161,13 @@ export default function PFD({ values: V, svt = true, inset = false, insetMode, n
|
|||||||
|
|
||||||
const nav = activeNav(V, flightPlan);
|
const nav = activeNav(V, flightPlan);
|
||||||
const vnav = vnavInfo(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 (
|
return (
|
||||||
<div className="pfd-wrap" ref={wrapRef}>
|
<div className="pfd-wrap" ref={wrapRef}>
|
||||||
@@ -127,7 +179,7 @@ export default function PFD({ values: V, svt = true, inset = false, insetMode, n
|
|||||||
{inset && insetBox && (
|
{inset && insetBox && (
|
||||||
<div className="pfd-inset" style={insetBox}>
|
<div className="pfd-inset" style={insetBox}>
|
||||||
<MapView values={V} flightPlan={flightPlan} fp={fp} inset
|
<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>
|
</div>
|
||||||
)}
|
)}
|
||||||
<svg ref={svgRef} className="g1000" viewBox={`0 0 ${W} ${H}`} preserveAspectRatio="xMidYMid meet">
|
<svg ref={svgRef} className="g1000" viewBox={`0 0 ${W} ${H}`} preserveAspectRatio="xMidYMid meet">
|
||||||
@@ -140,19 +192,25 @@ export default function PFD({ values: V, svt = true, inset = false, insetMode, n
|
|||||||
</linearGradient>
|
</linearGradient>
|
||||||
</defs>
|
</defs>
|
||||||
{!svt && <rect x="0" y="0" width={W} height={H} fill="#000" />}
|
{!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} />}
|
{nav && <NavStatus nav={nav} />}
|
||||||
{vnav && <VnavBox vnav={vnav} />}
|
{vnav && <VnavBox vnav={vnav} />}
|
||||||
<Attitude V={V} svt={svt} />
|
<Attitude V={V} svt={svt} />
|
||||||
<AirspeedTape V={V} />
|
<AFCS V={V} />
|
||||||
<AltitudeTape V={V} />
|
<Marker V={V} />
|
||||||
|
<AirspeedTape V={V} ias={iasS} />
|
||||||
|
<AltitudeTape V={V} alt={altS} vs={vsS} />
|
||||||
<GlideSlope V={V} />
|
<GlideSlope V={V} />
|
||||||
<HSI V={V} nav={nav} />
|
<HSI V={V} nav={nav} hdg={hdgS} />
|
||||||
<HdgCrsBoxes V={V} nav={nav} />
|
<HdgCrsBoxes V={V} nav={nav} />
|
||||||
|
<Wind V={V} />
|
||||||
<DataStrip V={V} />
|
<DataStrip V={V} />
|
||||||
</svg>
|
</svg>
|
||||||
{nrst && <Nearest values={V} onClose={onCloseNrst} />}
|
{nrst && <Nearest values={V} onClose={onCloseNrst} />}
|
||||||
{tmr && <TimerRef values={V} onClose={onCloseTmr} />}
|
{tmr && <TimerRef values={V} onClose={onCloseTmr} />}
|
||||||
|
{dme && <DmeWindow V={V} onClose={onCloseDme} />}
|
||||||
|
{alerts && <AlertsWindow V={V} onClose={onCloseAlerts} />}
|
||||||
|
{tune && <RadioTuner values={V} command={command} radio={tune} onClose={() => setTune(null)} />}
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -161,7 +219,7 @@ export default function PFD({ values: V, svt = true, inset = false, insetMode, n
|
|||||||
// Matches the XPLANE 1000: NAV cyan (active boxed), COM green active /
|
// Matches the XPLANE 1000: NAV cyan (active boxed), COM green active /
|
||||||
// cyan-boxed standby, a centre flight-plan cell with DIS/BRG, ⇄ swap arrows.
|
// cyan-boxed standby, a centre flight-plan cell with DIS/BRG, ⇄ swap arrows.
|
||||||
const SWAP = '⇔';
|
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>;
|
const swap = (x, y) => <text x={x} y={y} fill="#0ff" fontSize="17" fontFamily="monospace" textAnchor="middle">{SWAP}</text>;
|
||||||
return (
|
return (
|
||||||
<g fontFamily="monospace">
|
<g fontFamily="monospace">
|
||||||
@@ -200,6 +258,113 @@ function RadioBar({ V }) {
|
|||||||
{swap(844, 60)}
|
{swap(844, 60)}
|
||||||
<text x="950" y="60" fill="#fff" fontSize="19" textAnchor="end">{comF(V.com2Sb)}</text>
|
<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>
|
<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>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ---------------- 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>
|
</g>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -211,14 +376,21 @@ function Attitude({ V, svt }) {
|
|||||||
const cx = W / 2, cy = 270;
|
const cx = W / 2, cy = 270;
|
||||||
const rollRef = useRef(null), pitchRef = useRef(null), fdRef = useRef(null), bankRef = useRef(null);
|
const rollRef = useRef(null), pitchRef = useRef(null), fdRef = useRef(null), bankRef = useRef(null);
|
||||||
// Target attitude (updated every render); a rAF loop eases the displayed
|
// Target attitude (updated every render); a rAF loop eases the displayed
|
||||||
// transforms toward it at 60 fps — decoupled from X-Plane's ~20 Hz samples,
|
// transforms toward it — decoupled from X-Plane's ~20 Hz samples, so the
|
||||||
// so the horizon glides instead of stepping.
|
// 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 });
|
const tgt = useRef({ p: 0, r: 0, fp: 0, fr: 0 });
|
||||||
tgt.current = { p: pitch, r: roll, fp: pitch - fdP, fr: roll - fdR };
|
tgt.current = { p: pitch, r: roll, fp: pitch - fdP, fr: roll - fdR };
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
let raf; const d = { ...tgt.current };
|
let raf, last = 0;
|
||||||
const loop = () => {
|
const d = { ...tgt.current };
|
||||||
const t = tgt.current, k = 0.4;
|
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.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;
|
d.fp += (t.fp - d.fp) * k; d.fr += (t.fr - d.fr) * k;
|
||||||
rollRef.current?.setAttribute('transform', `rotate(${-d.r} ${cx} ${cy})`);
|
rollRef.current?.setAttribute('transform', `rotate(${-d.r} ${cx} ${cy})`);
|
||||||
@@ -322,8 +494,9 @@ function rollArc(cx, cy, slip, bankRef) {
|
|||||||
// V-speed reference marks for the C172 (KIAS), shown below the tape like the
|
// 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).
|
// 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' }];
|
const VSPEEDS = [{ s: 74, l: 'Y' }, { s: 62, l: 'X' }, { s: 68, l: 'G' }];
|
||||||
function AirspeedTape({ V }) {
|
function AirspeedTape({ V, ias: iasProp }) {
|
||||||
const ias = num(V.airspeed), tas = num(V.tas), spdBug = num(V.apSpdBug);
|
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 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 W2 = 84, sx = x + W2 - 7; // colour strip at the right inner edge
|
||||||
const ticks = [];
|
const ticks = [];
|
||||||
@@ -339,6 +512,13 @@ 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 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 bugY = Math.max(top, Math.min(top + h, cy + (ias - spdBug) * px));
|
||||||
const valid = ias >= 20;
|
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 (
|
return (
|
||||||
<g fontFamily="monospace">
|
<g fontFamily="monospace">
|
||||||
<rect x={x} y={top} width={W2} height={h} fill="#9aa6b3" fillOpacity="0.34" />
|
<rect x={x} y={top} width={W2} height={h} fill="#9aa6b3" fillOpacity="0.34" />
|
||||||
@@ -348,22 +528,37 @@ function AirspeedTape({ V }) {
|
|||||||
{band(129, 163, '#e0d000')}
|
{band(129, 163, '#e0d000')}
|
||||||
<rect x={sx} y={yOf(180)} width={7} height={Math.max(0, yOf(163) - yOf(180))} fill="#d01010" />
|
<rect x={sx} y={yOf(180)} width={7} height={Math.max(0, yOf(163) - yOf(180))} fill="#d01010" />
|
||||||
{ticks}
|
{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) */}
|
{/* selected-airspeed bug (cyan) */}
|
||||||
<path d={`M${x + W2} ${bugY - 7} h-7 v14 h7 z`} fill="none" stroke="#0ff" strokeWidth="2" />
|
<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) */}
|
{/* 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}`}
|
<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" />
|
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>
|
<text x={x + W2 - 22} y={cy + 9} textAnchor="end" fill="#fff" fontSize="30" fontWeight="bold">{valid ? Math.round(ias) : '- - -'}</text>
|
||||||
{/* TAS readout directly below the tape, like the real G1000 */}
|
{/* V-speed reference bugs (Vy/Vx/Vg) below the tape — like the real G1000 */}
|
||||||
<text x={x + 4} y={top + h + 22} fill="#fff" fontSize="15">TAS</text>
|
{VSPEEDS.map((v, i) => (
|
||||||
<text x={x + W2} y={top + h + 22} textAnchor="end" fill="#fff" fontSize="16">{Math.round(tas)}<tspan fontSize="12">KT</tspan></text>
|
<g key={v.l}>
|
||||||
|
<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 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>
|
</g>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
/* ---------------- altitude tape + VSI + baro ---------------- */
|
/* ---------------- altitude tape + VSI + baro ---------------- */
|
||||||
function AltitudeTape({ V }) {
|
function AltitudeTape({ V, alt: altProp, vs: vsProp }) {
|
||||||
const alt = num(V.altitude), vs = num(V.vspeed), altBug = num(V.apAltBug), baro = num(V.baro, 29.92);
|
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 x = W - 70 - 84, W2 = 84, top = 110, h = 350, cy = top + h / 2, px = 0.42;
|
||||||
const ticks = [];
|
const ticks = [];
|
||||||
const lo = Math.floor((alt - 420) / 100) * 100;
|
const lo = Math.floor((alt - 420) / 100) * 100;
|
||||||
@@ -374,6 +569,19 @@ function AltitudeTape({ V }) {
|
|||||||
<text x={x + W2 - 24} y={y + 7} textAnchor="end" fill="#fff" fontSize="19" fontFamily="monospace">{a}</text></g>);
|
<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));
|
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*
|
// 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
|
// through 20-ft steps, so you always see the value you're between — exactly
|
||||||
// like the mechanical tens drum on the real GDU 1040.
|
// like the mechanical tens drum on the real GDU 1040.
|
||||||
@@ -386,11 +594,18 @@ function AltitudeTape({ V }) {
|
|||||||
const drumX = x + W2 + 4, drumW = 26, drumCx = drumX + drumW / 2;
|
const drumX = x + W2 + 4, drumW = 26, drumCx = drumX + drumW / 2;
|
||||||
return (
|
return (
|
||||||
<g fontFamily="monospace">
|
<g fontFamily="monospace">
|
||||||
{/* selected altitude (cyan) above the tape */}
|
{/* selected altitude above the tape — flashes when approaching, amber on
|
||||||
<rect x={x - 6} y={top - 32} width={W2 + 6} height={26} fill="#000" stroke="#0ff" strokeWidth="1.4" />
|
deviation after capture (altitude alerter) */}
|
||||||
<text x={x + W2 - 6} y={top - 13} textAnchor="end" fill="#0ff" fontSize="19">{selStr}</text>
|
<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" />
|
<rect x={x} y={top} width={W2} height={h} fill="#9aa6b3" fillOpacity="0.34" />
|
||||||
{ticks}
|
{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 */}
|
{/* selected-altitude bug (cyan) on the tape */}
|
||||||
<path d={`M${x} ${bugY - 7} h7 v14 h-7 z`} fill="none" stroke="#0ff" strokeWidth="2" />
|
<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
|
{/* current-altitude readout (points left toward the tape): static hundreds
|
||||||
@@ -398,9 +613,9 @@ function AltitudeTape({ V }) {
|
|||||||
values are visible at once with the pointer between them (GDU 1040). */}
|
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>
|
<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}`}
|
<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" />
|
fill="#000" stroke={deviated ? '#ffce46' : '#fff'} strokeWidth="2" />
|
||||||
<text x={drumX - 3} y={cy + 9} textAnchor="end" fill="#fff" fontSize="27" fontWeight="bold">{hi}</text>
|
<text x={drumX - 3} y={cy + 9} textAnchor="end" fill={deviated ? '#ffce46' : '#fff'} fontSize="27" fontWeight="bold">{hi}</text>
|
||||||
<g clipPath="url(#altdrum)" fill="#fff" fontSize="20" fontWeight="bold">
|
<g clipPath="url(#altdrum)" fill={deviated ? '#ffce46' : '#fff'} fontSize="20" fontWeight="bold">
|
||||||
{[-1, 0, 1, 2].map((k) => {
|
{[-1, 0, 1, 2].map((k) => {
|
||||||
const v = base + k * STEP;
|
const v = base + k * STEP;
|
||||||
const s = String(((v % 100) + 100) % 100).padStart(2, '0');
|
const s = String(((v % 100) + 100) % 100).padStart(2, '0');
|
||||||
@@ -436,14 +651,20 @@ function VSI({ x, cy, h, vs, bug }) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/* ---------------- HSI compass rose ---------------- */
|
/* ---------------- HSI compass rose ---------------- */
|
||||||
function HSI({ V, nav }) {
|
function HSI({ V, nav, hdg: hdgProp }) {
|
||||||
const hdg = ((num(V.heading) % 360) + 360) % 360;
|
const hdg = hdgProp != null ? hdgProp : ((num(V.heading) % 360) + 360) % 360;
|
||||||
const bug = num(V.apHdgBug);
|
const bug = num(V.apHdgBug);
|
||||||
// With an active flight-plan leg the CDI follows OUR GPS guidance (desired
|
// CDI source mirrors the in-sim G1000: 2 = GPS (magenta), 0/1 = VLOC1/2 (green).
|
||||||
// track + cross-track); otherwise it mirrors the sim's nav source.
|
// With GPS source + an active leg the CDI follows OUR GPS guidance (desired
|
||||||
const crs = nav ? nav.dtk : num(V.obsCrs, 360);
|
// track + cross-track); on VLOC it follows the sim's VOR/LOC needle.
|
||||||
const def = nav ? nav.def : num(V.hsiDef);
|
const src = Math.round(num(V.cdiSrc, 2)); // default GPS when unknown
|
||||||
const toFrom = nav ? 1 : num(V.hsiToFrom);
|
const isGps = src === 2;
|
||||||
|
const useNav = isGps && !!nav;
|
||||||
|
const C = isGps ? '#e040fb' : '#00d800'; // magenta GPS / green VLOC
|
||||||
|
const srcLabel = isGps ? 'GPS' : (src === 1 ? 'VLOC2' : 'VLOC1');
|
||||||
|
const crs = useNav ? nav.dtk : num(V.obsCrs, 360);
|
||||||
|
const def = useNav ? nav.def : num(V.hsiDef);
|
||||||
|
const toFrom = useNav ? 1 : num(V.hsiToFrom);
|
||||||
const cx = W / 2, cy = 630, r = 130;
|
const cx = W / 2, cy = 630, r = 130;
|
||||||
|
|
||||||
const ticks = [];
|
const ticks = [];
|
||||||
@@ -475,30 +696,72 @@ function HSI({ V, nav }) {
|
|||||||
<g transform={`rotate(${bugA} ${cx} ${cy})`}>
|
<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" />
|
<path d={`M${cx} ${cy - r} l-10 -12 h6 v12 h8 v-12 h6 z`} fill="#0ff" stroke="#000" />
|
||||||
</g>
|
</g>
|
||||||
{/* GPS source label */}
|
{/* CDI source label (GPS magenta / VLOC green) */}
|
||||||
<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={C} fontSize="15">{srcLabel}</text>
|
||||||
<text x={cx + 56} y={cy - 10} textAnchor="middle" fill="#e040fb" fontSize="15">ENR</text>
|
{isGps && <text x={cx + 56} y={cy - 10} textAnchor="middle" fill={C} fontSize="15">ENR</text>}
|
||||||
{/* course pointer + CDI (magenta = GPS source) */}
|
{/* course pointer + CDI */}
|
||||||
<g transform={`rotate(${crsA} ${cx} ${cy})`}>
|
<g transform={`rotate(${crsA} ${cx} ${cy})`}>
|
||||||
<line x1={cx} y1={cy - r + 18} x2={cx} y2={cy - 40} 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="#e040fb" />
|
<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="#e040fb" strokeWidth="4" />
|
<line x1={cx} y1={cy + 40} x2={cx} y2={cy + r - 18} stroke={C} strokeWidth="4" />
|
||||||
{/* CDI deviation bar */}
|
{/* 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" />)}
|
{[-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
|
{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}`
|
||||||
: `${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>
|
</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 && (
|
{nav && (
|
||||||
<g transform={`rotate(${nav.brg - hdg} ${cx} ${cy})`}>
|
<g transform={`rotate(${nav.brg - hdg} ${cx} ${cy})`} stroke="#0ff" fill="none" strokeWidth="2.5">
|
||||||
<line x1={cx} y1={cy - r + 2} x2={cx} y2={cy - r + 30} stroke="#0ff" strokeWidth="3" />
|
<polygon points={`${cx},${cy - r + 2} ${cx - 8},${cy - r + 22} ${cx + 8},${cy - r + 22}`} />
|
||||||
<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 - 3} y1={cy - r + 22} x2={cx - 3} y2={cy - 36} />
|
||||||
<line x1={cx} y1={cy + r - 30} x2={cx} y2={cy + r - 2} stroke="#0ff" strokeWidth="3" />
|
<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>
|
</g>
|
||||||
)}
|
)}
|
||||||
<rect x={cx - 7} y={cy - 7} width={14} height={14} fill="#ffcc00" stroke="#000" strokeWidth="2" />
|
<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>
|
</g>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -579,6 +842,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 ---------------- */
|
/* ---------------- bottom data line: OAT / ISA / XPDR / LCL ---------------- */
|
||||||
function DataStrip({ V }) {
|
function DataStrip({ V }) {
|
||||||
const oatC = num(V.oat);
|
const oatC = num(V.oat);
|
||||||
|
|||||||
@@ -21,6 +21,7 @@ export default function Proc({ xp, onClose }) {
|
|||||||
const [procs, setProcs] = useState(null);
|
const [procs, setProcs] = useState(null);
|
||||||
const [err, setErr] = useState('');
|
const [err, setErr] = useState('');
|
||||||
const [cat, setCat] = useState('approach');
|
const [cat, setCat] = useState('approach');
|
||||||
|
const [view, setView] = useState('menu'); // 'menu' (PDF action list) | 'pick'
|
||||||
const [selProc, setSelProc] = useState(null); // { name, transitions }
|
const [selProc, setSelProc] = useState(null); // { name, transitions }
|
||||||
const [selTrans, setSelTrans] = useState('');
|
const [selTrans, setSelTrans] = useState('');
|
||||||
const [legs, setLegs] = useState([]);
|
const [legs, setLegs] = useState([]);
|
||||||
@@ -59,12 +60,39 @@ export default function Proc({ xp, onClose }) {
|
|||||||
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 (
|
return (
|
||||||
<div className="dlg-backdrop" onClick={onClose}>
|
<div className="gwin-backdrop" onClick={onClose}>
|
||||||
<div className="dlg proc" onClick={(e) => e.stopPropagation()}>
|
<div className="dlg proc menu" onClick={(e) => e.stopPropagation()}>
|
||||||
<div className="dlg-head">PROCEDURES</div>
|
<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="gwin-backdrop" onClick={onClose}>
|
||||||
|
<div className="dlg proc" onClick={(e) => e.stopPropagation()}>
|
||||||
|
<div className="dlg-head">{catLabel}</div>
|
||||||
<div className="proc-body">
|
<div className="proc-body">
|
||||||
<div className="proc-apt">
|
<div className="proc-apt">
|
||||||
|
<button className="proc-back" onClick={() => setView('menu')}>‹</button>
|
||||||
<label>APT</label>
|
<label>APT</label>
|
||||||
<input value={query} onChange={(e) => setQuery(e.target.value.toUpperCase())}
|
<input value={query} onChange={(e) => setQuery(e.target.value.toUpperCase())}
|
||||||
onKeyDown={(e) => e.key === 'Enter' && setIcao(query)}
|
onKeyDown={(e) => e.key === 'Enter' && setIcao(query)}
|
||||||
@@ -73,13 +101,6 @@ export default function Proc({ xp, onClose }) {
|
|||||||
</div>
|
</div>
|
||||||
{err && <div className="proc-err">{err}</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-cols">
|
||||||
<div className="proc-list">
|
<div className="proc-list">
|
||||||
<div className="proc-coltitle">{procs ? `${catList.length}` : '—'} PROC</div>
|
<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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -46,7 +46,6 @@ export default function TimerRef({ values, onClose }) {
|
|||||||
<div className="tmr-window">
|
<div className="tmr-window">
|
||||||
<div className="nrst-head">
|
<div className="nrst-head">
|
||||||
<span className="nrst-title">TIMER / REFERENCES</span>
|
<span className="nrst-title">TIMER / REFERENCES</span>
|
||||||
{onClose && <button className="nrst-x" onClick={onClose}>✕</button>}
|
|
||||||
</div>
|
</div>
|
||||||
<div className="tmr-body">
|
<div className="tmr-body">
|
||||||
<div className="tmr-clock">{fmt(shown)}</div>
|
<div className="tmr-clock">{fmt(shown)}</div>
|
||||||
|
|||||||
+214
-36
@@ -6,15 +6,17 @@
|
|||||||
/* App chrome (everything that is NOT a G1000 instrument): same clean
|
/* App chrome (everything that is NOT a G1000 instrument): same clean
|
||||||
macOS-dark look as the desktop launcher. */
|
macOS-dark look as the desktop launcher. */
|
||||||
--ui-font: 'Inter', -apple-system, BlinkMacSystemFont, 'SF Pro Text', 'Segoe UI', system-ui, sans-serif;
|
--ui-font: 'Inter', -apple-system, BlinkMacSystemFont, 'SF Pro Text', 'Segoe UI', system-ui, sans-serif;
|
||||||
--c-bg: #1c1c1e;
|
/* monochrome chrome palette (191919 / 0f0f0f / f0f0f0) — no colour accent */
|
||||||
--c-surface: #2c2c2e;
|
--c-bg: #0f0f0f;
|
||||||
--c-fill: #3a3a3c;
|
--c-surface: #191919;
|
||||||
--c-line: #48484a;
|
--c-fill: #232323;
|
||||||
--c-line-soft: #38383a;
|
--c-line: #3a3a3a;
|
||||||
--c-txt: #ffffff;
|
--c-line-soft: #2a2a2a;
|
||||||
--c-txt2: #ebebf5;
|
--c-txt: #f0f0f0;
|
||||||
--c-mut: #8e8e93;
|
--c-txt2: #e0e0e0;
|
||||||
--c-green: #30d158;
|
--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-amber: #ffd60a;
|
||||||
--c-red: #ff453a;
|
--c-red: #ff453a;
|
||||||
color-scheme: dark;
|
color-scheme: dark;
|
||||||
@@ -54,7 +56,7 @@ body {
|
|||||||
}
|
}
|
||||||
.sb-top:hover { background: #34343a; }
|
.sb-top:hover { background: #34343a; }
|
||||||
.brand { font-weight: 700; font-size: 17px; letter-spacing: .3px; white-space: nowrap; }
|
.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; }
|
.sb-chev { color: var(--c-mut); font-size: 12px; }
|
||||||
.app.nav-narrow .brand span { display: none; }
|
.app.nav-narrow .brand span { display: none; }
|
||||||
.app.nav-narrow .sb-chev { display: none; }
|
.app.nav-narrow .sb-chev { display: none; }
|
||||||
@@ -70,7 +72,7 @@ body {
|
|||||||
transition: background .12s, color .12s;
|
transition: background .12s, color .12s;
|
||||||
}
|
}
|
||||||
.snav-i:hover { background: var(--c-surface); color: var(--c-txt2); }
|
.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-ic { flex: 0 0 22px; }
|
||||||
.snav-lbl { white-space: nowrap; }
|
.snav-lbl { white-space: nowrap; }
|
||||||
.app.nav-narrow .snav-lbl { display: none; }
|
.app.nav-narrow .snav-lbl { display: none; }
|
||||||
@@ -157,16 +159,35 @@ body {
|
|||||||
.pfd-inset .mapwrap, .pfd-inset .leaflet-host { width: 100%; height: 100%; }
|
.pfd-inset .mapwrap, .pfd-inset .leaflet-host { width: 100%; height: 100%; }
|
||||||
.mapwrap.inset .leaflet-control-container { display: none; }
|
.mapwrap.inset .leaflet-control-container { display: none; }
|
||||||
/* NRST (nearest) window — pops over the right side of the PFD */
|
/* 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 {
|
.nrst-window {
|
||||||
position: absolute; z-index: 4; top: 9%; right: 1.5%; width: 41%; max-width: 440px;
|
position: absolute; z-index: 4; right: 2%; top: 50%; bottom: 11%; width: 31%; max-width: 320px;
|
||||||
background: rgba(8, 10, 12, 0.94); border: 1px solid #4a5560; border-radius: 3px;
|
display: flex; flex-direction: column;
|
||||||
color: #fff; font-family: 'Roboto Mono', monospace; box-shadow: 0 4px 18px rgba(0,0,0,0.6);
|
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-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 { 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 { 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-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, .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; }
|
.nrst-cols { color: #6f808d; font-size: 10px; border-bottom: 1px solid #222; padding-bottom: 4px; }
|
||||||
@@ -177,15 +198,99 @@ body {
|
|||||||
.nrst-row .c-xtra { color: #39d3c0; text-align: right; }
|
.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-row .c-name { grid-column: 1 / -1; color: #8b9aa6; font-size: 10px; margin-top: -1px; }
|
||||||
.nrst-list { max-height: 62vh; overflow-y: auto; }
|
.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; top: 13%; left: 1.5%; width: 30%; max-width: 270px;
|
||||||
|
background: #05080b; border: 1px solid #7e8a94; border-radius: 0;
|
||||||
|
color: #fff; font-family: 'Roboto Mono', monospace;
|
||||||
|
}
|
||||||
|
.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; }
|
||||||
|
.pfd-pop.alerts { top: 46%; } /* below the DME window so both can be open */
|
||||||
|
/* 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: #0ff; text-align: right; }
|
||||||
|
/* 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; }
|
.nrst-empty { color: #6f808d; text-align: center; padding: 12px; font-size: 12px; }
|
||||||
/* XPDR squawk-entry readout above the softkey keypad */
|
/* 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 { 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; }
|
.squawk-entry b { color: #19ff19; font-size: 18px; letter-spacing: 5px; margin-left: 6px; }
|
||||||
/* TMR/REF window — left side of the PFD */
|
/* TMR/REF window — left side of the PFD */
|
||||||
.tmr-window {
|
.tmr-window {
|
||||||
position: absolute; z-index: 4; top: 9%; left: 1.5%; width: 30%; max-width: 320px;
|
position: absolute; z-index: 4; bottom: 11%; right: 2%; width: 31%; max-width: 320px;
|
||||||
background: rgba(8, 10, 12, 0.94); border: 1px solid #4a5560; border-radius: 3px;
|
background: #05080b; border: 1px solid #7e8a94; border-radius: 0;
|
||||||
color: #fff; font-family: 'Roboto Mono', monospace; box-shadow: 0 4px 18px rgba(0,0,0,0.6);
|
color: #fff; font-family: 'Roboto Mono', monospace;
|
||||||
}
|
}
|
||||||
.tmr-body { padding: 8px 10px; }
|
.tmr-body { padding: 8px 10px; }
|
||||||
.tmr-clock { font-size: 34px; font-weight: bold; text-align: center; color: #fff; letter-spacing: 2px; }
|
.tmr-clock { font-size: 34px; font-weight: bold; text-align: center; color: #fff; letter-spacing: 2px; }
|
||||||
@@ -210,24 +315,35 @@ body {
|
|||||||
.tmr-minalert { margin-top: 6px; text-align: center; background: #ffd24a; color: #1a1400; font-weight: bold; padding: 3px; border-radius: 2px; letter-spacing: 2px; }
|
.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, …) */
|
/* 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-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); }
|
/* G1000 side-window dialogs (PROC / Direct-To / FPL): compact panels in the
|
||||||
.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; }
|
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 2% 11% 0; }
|
||||||
|
.dlg { background: #05080b; border: 1px solid #7e8a94; border-radius: 0; min-width: 280px; color: #fff; font-family: 'Roboto Mono', monospace; }
|
||||||
|
.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-arrow { color: #e040fb; margin-right: 8px; }
|
||||||
.dto-body { padding: 12px; }
|
.dto-body { padding: 12px; }
|
||||||
.dto-lbl { color: #6f808d; font-size: 11px; display: block; margin-bottom: 4px; }
|
.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-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 { 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 { 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.on { border-color: #36d2ff; background: #0d2c38; }
|
||||||
.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-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-tgt { display: flex; align-items: baseline; gap: 10px; margin-top: 10px; }
|
||||||
.dto-sel .dto-id { color: #0ff; font-size: 20px; font-weight: bold; }
|
.dto-tgt .dto-id { color: #36d2ff; font-size: 22px; font-weight: bold; letter-spacing: 1px; }
|
||||||
.dto-sel .dto-type { color: #0a8; font-size: 11px; }
|
.dto-tgt .dto-type { color: #6f808d; font-size: 11px; }
|
||||||
.dto-sel .dto-vec { color: #e040fb; margin-left: auto; font-weight: bold; }
|
.dto-grid { display: grid; grid-template-columns: auto 1fr auto 1fr; align-items: baseline; gap: 6px 8px; margin-top: 10px; padding-top: 8px; border-top: 1px solid #222; }
|
||||||
|
.dto-grid b { color: #6f808d; font-weight: normal; font-size: 12px; }
|
||||||
|
.dto-grid span { color: #fff; font-size: 15px; }
|
||||||
.dlg-actions { display: flex; gap: 8px; padding: 10px 12px; border-top: 1px solid #2c343c; }
|
.dlg-actions { display: flex; gap: 8px; padding: 10px 12px; border-top: 1px solid #2c343c; }
|
||||||
.dlg-actions .fbtn { flex: 1; }
|
.dlg-actions .fbtn { flex: 1; }
|
||||||
/* PROC dialog */
|
/* PROC dialog */
|
||||||
.dlg.proc { width: 640px; max-width: 92vw; }
|
.dlg.proc { width: 400px; max-width: 38%; }
|
||||||
|
.dlg.proc.menu { width: 300px; }
|
||||||
|
.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: 14px; text-align: left; padding: 8px 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-body { padding: 12px; }
|
||||||
.proc-apt { display: flex; align-items: center; gap: 8px; }
|
.proc-apt { display: flex; align-items: center; gap: 8px; }
|
||||||
.proc-apt label { color: #6f808d; font-size: 11px; }
|
.proc-apt label { color: #6f808d; font-size: 11px; }
|
||||||
@@ -236,13 +352,13 @@ body {
|
|||||||
.proc-err { color: #ffae42; font-size: 12px; margin-top: 6px; }
|
.proc-err { color: #ffae42; font-size: 12px; margin-top: 6px; }
|
||||||
.proc-tabs { display: flex; gap: 4px; margin: 10px 0 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 { 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-tabs button.on { background: #19b8e6; color: #042230; font-weight: bold; border-color: #19b8e6; }
|
||||||
.proc-cols { display: grid; grid-template-columns: 1fr 1fr 1.4fr; gap: 6px; height: 300px; }
|
.proc-cols { display: grid; grid-template-columns: 1fr 1fr 1.3fr; gap: 5px; height: 220px; }
|
||||||
.proc-list, .proc-preview { background: #05080b; border: 1px solid #1c242c; overflow-y: auto; display: flex; flex-direction: column; }
|
.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-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 { 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: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-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 { 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; }
|
.proc-leg b { color: #0ff; } .proc-leg u { color: #39d3c0; font-size: 10px; text-decoration: none; margin-left: auto; }
|
||||||
@@ -258,7 +374,7 @@ body {
|
|||||||
|
|
||||||
/* ---- GDU-1040 bezel ---- */
|
/* ---- GDU-1040 bezel ---- */
|
||||||
.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);
|
background: linear-gradient(150deg, #3a3c40, #202123 55%, #2c2d30);
|
||||||
border-radius: 18px; padding: 12px; box-shadow: inset 0 1px 0 #4a4c50, 0 8px 30px #000;
|
border-radius: 18px; padding: 12px; box-shadow: inset 0 1px 0 #4a4c50, 0 8px 30px #000;
|
||||||
font-family: 'Saira Semi Condensed', sans-serif;
|
font-family: 'Saira Semi Condensed', sans-serif;
|
||||||
@@ -267,9 +383,18 @@ body {
|
|||||||
.bezel-title { text-align: center; color: #c9ced3; font-size: 14px; font-weight: 700; letter-spacing: 3px; padding: 2px 0 6px; }
|
.bezel-title { text-align: center; color: #c9ced3; font-size: 14px; font-weight: 700; letter-spacing: 3px; padding: 2px 0 6px; }
|
||||||
.bezel-screen {
|
.bezel-screen {
|
||||||
flex: 1; background: #000; overflow: hidden; position: relative;
|
flex: 1; background: #000; overflow: hidden; position: relative;
|
||||||
display: flex; min-height: 0;
|
display: flex; flex-direction: column; min-height: 0;
|
||||||
}
|
}
|
||||||
.bezel-screen > * { width: 100%; height: 100%; }
|
.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; }
|
.softkeys { display: grid; grid-template-columns: repeat(12, 1fr); gap: 4px; padding: 4px 2px 1px; }
|
||||||
.softkey {
|
.softkey {
|
||||||
height: 20px; display: flex; align-items: center; justify-content: center;
|
height: 20px; display: flex; align-items: center; justify-content: center;
|
||||||
@@ -283,12 +408,19 @@ body {
|
|||||||
.softkey.on { background: #e8edf2; color: #0a0c0e; border-top-color: #fff; font-weight: 800; }
|
.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; }
|
.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 { display: flex; flex-direction: column; align-items: center; padding: 12px 6px; gap: 14px; flex: 0 0 104px; width: 104px; }
|
||||||
.bezel-knobs.left { width: 88px; } .bezel-knobs.right { width: 100px; }
|
/* 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-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-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-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-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 {
|
.knob.outer {
|
||||||
width: 60px; height: 60px; 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;
|
background: radial-gradient(circle at 35% 30%, #55585d, #2a2c2f 70%); box-shadow: 0 2px 5px #000, inset 0 1px 0 #6a6d72;
|
||||||
@@ -296,6 +428,13 @@ body {
|
|||||||
.knob-wrap.big .knob.outer { width: 68px; height: 68px; }
|
.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.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; }
|
.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 { cursor: pointer; border: none; padding: 0; }
|
||||||
.knob.outer:active { box-shadow: 0 1px 2px #000, inset 0 2px 4px #000; }
|
.knob.outer:active { box-shadow: 0 1px 2px #000, inset 0 2px 4px #000; }
|
||||||
|
|
||||||
@@ -316,6 +455,41 @@ body {
|
|||||||
.set-opt { display: flex; gap: 8px; }
|
.set-opt { display: flex; gap: 8px; }
|
||||||
.set-opt .fbtn { flex: 1; }
|
.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); }
|
.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 { display: grid; grid-template-columns: repeat(2, 14px); gap: 2px; margin-top: 3px; }
|
||||||
.pan-pad button {
|
.pan-pad button {
|
||||||
@@ -363,6 +537,10 @@ body {
|
|||||||
font: 600 12px/1 monospace; color: #0ff; background: #000a; padding: 4px 6px; }
|
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 { width: 8px; height: 8px; border: 1px solid #0ff; display: inline-block; }
|
||||||
.mc-mode em.on { background: #0ff; }
|
.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) */
|
/* Autopilot — GMC-710-style AFCS mode controller (app chrome look) */
|
||||||
.afcs { width: 100%; height: 100%; display: flex; align-items: center; justify-content: center;
|
.afcs { width: 100%; height: 100%; display: flex; align-items: center; justify-content: center;
|
||||||
@@ -520,7 +698,7 @@ body {
|
|||||||
background: #08240f; color: #29f06a; border: 1px solid #0a5; border-radius: 8px;
|
background: #08240f; color: #29f06a; border: 1px solid #0a5; border-radius: 8px;
|
||||||
padding: 12px 16px; font-weight: 800; font-family: monospace; letter-spacing: 1px;
|
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.export { background: #ffae42; color: #2a1500; border-color: #ffae42; flex: 1; }
|
||||||
.fbtn:disabled { opacity: .4; }
|
.fbtn:disabled { opacity: .4; }
|
||||||
.fms-actions { display: flex; gap: 8px; margin-top: 8px; }
|
.fms-actions { display: flex; gap: 8px; margin-top: 8px; }
|
||||||
|
|||||||
Reference in New Issue
Block a user