Initial commit: X-Plane G1000 web cockpit + bridge + Tauri desktop app
- server/: Node bridge (datarefs/commands, navdata, CIFP procedures, flight plan) - web/: React cockpit (PFD/MFD/Map, VFR six-pack, AFCS, FMS CDU), PWA, collapsible sidebar - desktop/: Tauri 2 launcher (Bun sidecar, system tray, updater) + Linux build via Docker - scripts/: prep-desktop, build-linux, Gitea release + latest.json Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,177 @@
|
||||
import React, { useEffect, useRef } from 'react';
|
||||
import maplibregl from 'maplibre-gl';
|
||||
import 'maplibre-gl/dist/maplibre-gl.css';
|
||||
import { num } from '../api/useXplane.js';
|
||||
|
||||
// Synthetic Vision background: real-world 3D terrain (elevation tiles) rendered
|
||||
// in WebGL, with the camera placed at the aircraft and oriented by heading and
|
||||
// pitch. Bank (roll) is applied as a CSS rotation of the whole canvas. This is
|
||||
// the SVT *concept* using real-world DEM data — not X-Plane's own scenery.
|
||||
//
|
||||
// Free public elevation tiles: AWS "terrarium" (no API key needed).
|
||||
const STYLE = {
|
||||
version: 8,
|
||||
glyphs: 'https://fonts.openmaptiles.org/{fontstack}/{range}.pbf', // for runway-number labels
|
||||
sources: {
|
||||
dem: {
|
||||
type: 'raster-dem',
|
||||
tiles: ['https://s3.amazonaws.com/elevation-tiles-prod/terrarium/{z}/{x}/{y}.png'],
|
||||
encoding: 'terrarium',
|
||||
tileSize: 256,
|
||||
maxzoom: 11, // coarser cap = far fewer tiles to fetch
|
||||
attribution: 'Elevation: Mapzen/AWS',
|
||||
},
|
||||
},
|
||||
layers: [
|
||||
// background shows above the horizon = the sky
|
||||
{ id: 'bg', type: 'background', paint: { 'background-color': '#4a93da' } },
|
||||
{
|
||||
id: 'relief',
|
||||
type: 'color-relief',
|
||||
source: 'dem',
|
||||
paint: {
|
||||
'color-relief-color': [
|
||||
'interpolate', ['linear'], ['elevation'],
|
||||
-50, '#3d6ea5', 0, '#2e6b3a', 300, '#5a8f3c', 800, '#9aa84a',
|
||||
1500, '#b08f4e', 2500, '#8d6b4a', 3500, '#b9b0a6', 4500, '#ffffff',
|
||||
],
|
||||
},
|
||||
},
|
||||
{ id: 'hill', type: 'hillshade', source: 'dem', paint: { 'hillshade-exaggeration': 0.55 } },
|
||||
],
|
||||
terrain: { source: 'dem', exaggeration: 1.3 },
|
||||
};
|
||||
|
||||
// Build runway surfaces (+ threshold number labels) from the bridge's runway
|
||||
// list. Each runway becomes a ground-draped quad plus two rotated number tags.
|
||||
function runwayGeoJSON(list) {
|
||||
const feats = [];
|
||||
for (const r of list) {
|
||||
const midLat = (r.la1 + r.la2) / 2;
|
||||
const mLat = 111320, mLon = 111320 * Math.cos((midLat * Math.PI) / 180);
|
||||
const dx = (r.lo2 - r.lo1) * mLon, dy = (r.la2 - r.la1) * mLat;
|
||||
const len = Math.hypot(dx, dy) || 1;
|
||||
const hw = (r.w || 30) / 2;
|
||||
const dLon = ((-dy / len) * hw) / mLon, dLat = ((dx / len) * hw) / mLat;
|
||||
const c1 = [r.lo1 + dLon, r.la1 + dLat], c2 = [r.lo2 + dLon, r.la2 + dLat];
|
||||
const c3 = [r.lo2 - dLon, r.la2 - dLat], c4 = [r.lo1 - dLon, r.la1 - dLat];
|
||||
feats.push({ type: 'Feature', geometry: { type: 'Polygon', coordinates: [[c1, c2, c3, c4, c1]] }, properties: {} });
|
||||
const brg = (Math.atan2(dx, dy) * 180 / Math.PI + 360) % 360;
|
||||
feats.push({ type: 'Feature', geometry: { type: 'Point', coordinates: [r.lo1, r.la1] }, properties: { num: r.n1, rot: brg } });
|
||||
feats.push({ type: 'Feature', geometry: { type: 'Point', coordinates: [r.lo2, r.la2] }, properties: { num: r.n2, rot: (brg + 180) % 360 } });
|
||||
}
|
||||
return { type: 'FeatureCollection', features: feats };
|
||||
}
|
||||
|
||||
export default function SVT({ values }) {
|
||||
const elRef = useRef(null);
|
||||
const mapRef = useRef(null);
|
||||
const dataRef = useRef(values);
|
||||
dataRef.current = values;
|
||||
|
||||
useEffect(() => {
|
||||
let map;
|
||||
try {
|
||||
map = new maplibregl.Map({
|
||||
container: elRef.current,
|
||||
style: STYLE,
|
||||
center: [num(values.lon, -122.31), num(values.lat, 47.45)],
|
||||
zoom: 11.5,
|
||||
pitch: 72,
|
||||
bearing: num(values.heading),
|
||||
maxPitch: 76, // lower max pitch = nearer horizon = less distant terrain
|
||||
pixelRatio: 1, // don't render at 2× on retina — big perf/bandwidth win
|
||||
renderWorldCopies: false,
|
||||
maxTileCacheSize: 40,
|
||||
attributionControl: false,
|
||||
interactive: false,
|
||||
preserveDrawingBuffer: true,
|
||||
fadeDuration: 0,
|
||||
});
|
||||
mapRef.current = map;
|
||||
} catch (e) {
|
||||
// WebGL unavailable → the CSS gradient fallback stays visible.
|
||||
console.warn('SVT: WebGL init failed', e?.message);
|
||||
return;
|
||||
}
|
||||
|
||||
// Runways from X-Plane's nav data, draped on the terrain with their numbers.
|
||||
let rwyTimer;
|
||||
const addRunways = () => {
|
||||
if (map.getSource('runways')) return;
|
||||
map.addSource('runways', { type: 'geojson', data: { type: 'FeatureCollection', features: [] } });
|
||||
map.addLayer({ id: 'rwy-fill', type: 'fill', source: 'runways', filter: ['==', ['geometry-type'], 'Polygon'], paint: { 'fill-color': '#33373b', 'fill-opacity': 0.9 } });
|
||||
map.addLayer({ id: 'rwy-line', type: 'line', source: 'runways', filter: ['==', ['geometry-type'], 'Polygon'], paint: { 'line-color': '#e8edf2', 'line-width': 1.6 } });
|
||||
map.addLayer({
|
||||
id: 'rwy-num', type: 'symbol', source: 'runways', filter: ['==', ['geometry-type'], 'Point'],
|
||||
layout: {
|
||||
'text-field': ['get', 'num'], 'text-font': ['Open Sans Bold'], 'text-size': 15,
|
||||
'text-rotate': ['get', 'rot'], 'text-rotation-alignment': 'map', 'text-keep-upright': false,
|
||||
'text-allow-overlap': true, 'text-ignore-placement': true,
|
||||
},
|
||||
paint: { 'text-color': '#fff', 'text-halo-color': '#000', 'text-halo-width': 1.4 },
|
||||
});
|
||||
let last = null;
|
||||
const refresh = async () => {
|
||||
const v = dataRef.current, lat = num(v.lat), lon = num(v.lon);
|
||||
if (!isFinite(lat) || !isFinite(lon)) return;
|
||||
if (last && Math.abs(last[0] - lat) < 0.02 && Math.abs(last[1] - lon) < 0.02) return;
|
||||
last = [lat, lon];
|
||||
try {
|
||||
const res = await fetch(`/api/nav/runways?lat=${lat}&lon=${lon}&radius=15`);
|
||||
if (!res.ok) return;
|
||||
map.getSource('runways')?.setData(runwayGeoJSON(await res.json()));
|
||||
} catch { /* offline */ }
|
||||
};
|
||||
refresh();
|
||||
rwyTimer = setInterval(refresh, 4000);
|
||||
};
|
||||
|
||||
// Terrain awareness (TAWS): recolour the relief relative to aircraft
|
||||
// altitude — terrain within 1000 ft below = yellow, within 100 ft below or
|
||||
// above = red, otherwise normal. Stops are in metres (terrarium elevation).
|
||||
let lastBandM = null;
|
||||
const updateTerrainAwareness = (altFt) => {
|
||||
const altM = altFt * 0.3048;
|
||||
if (lastBandM != null && Math.abs(altM - lastBandM) < 12) return;
|
||||
lastBandM = altM;
|
||||
const yellowLo = altM - 305, redLo = altM - 30; // 1000 ft / 100 ft below
|
||||
const s = []; // [elevation, color] pairs, strictly increasing inputs
|
||||
const push = (e, c) => { if (!s.length || e > s[s.length - 2]) s.push(e, c); };
|
||||
push(-150, '#2f6a3c'); push(150, '#4f8a3e'); push(900, '#9a8a4a');
|
||||
push(yellowLo - 1, '#7d6a3a');
|
||||
push(yellowLo, '#e6c200'); push(redLo - 1, '#e6c200');
|
||||
push(redLo, '#e03030'); push(redLo + 4000, '#ff2a2a');
|
||||
try { map.setPaintProperty('relief', 'color-relief-color', ['interpolate', ['linear'], ['elevation'], ...s]); } catch { /* not ready */ }
|
||||
};
|
||||
|
||||
let raf;
|
||||
const tick = () => {
|
||||
const v = dataRef.current;
|
||||
// Keep the view close: higher zoom floor + capped pitch bounds the area.
|
||||
const zoom = Math.max(10.5, Math.min(12.5, 12.5 - num(v.altitude) / 3500));
|
||||
try {
|
||||
map.jumpTo({
|
||||
center: [num(v.lon, -122.31), num(v.lat, 47.45)],
|
||||
bearing: num(v.heading),
|
||||
pitch: Math.max(58, Math.min(76, 72 + num(v.pitch))),
|
||||
zoom,
|
||||
});
|
||||
updateTerrainAwareness(num(v.altitude, 5500));
|
||||
} catch { /* style not ready yet */ }
|
||||
raf = requestAnimationFrame(tick);
|
||||
};
|
||||
map.on('load', () => { addRunways(); raf = requestAnimationFrame(tick); });
|
||||
|
||||
return () => { cancelAnimationFrame(raf); clearInterval(rwyTimer); map.remove(); mapRef.current = null; };
|
||||
}, []); // eslint-disable-line
|
||||
|
||||
// Bank: rotate the whole terrain canvas opposite to aircraft roll; scale up so
|
||||
// the corners stay covered while rotated.
|
||||
const roll = num(values.roll);
|
||||
return (
|
||||
<div className="svt-fallback">
|
||||
<div ref={elRef} className="svt-canvas" style={{ transform: `rotate(${-roll}deg) scale(1.5)` }} />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user