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:
2026-06-01 15:07:03 +02:00
commit ebc33a78b7
110 changed files with 14671 additions and 0 deletions
+177
View File
@@ -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>
);
}