Files
xplane-cockpit/web/src/components/SVT.jsx
T
karim 5b7cf13e9d SVT: align synthetic terrain horizon with the attitude horizon
The 3D terrain showed almost no sky — the horizon sat far above the attitude
horizon line. Base camera pitch was 72°, but with MapLibre's 36.87° vertical FOV
the flat horizon only reaches the attitude line (28% of the SVT box) at ~82°.
Invert the perspective to derive the camera pitch from aircraft pitch so the
synthetic horizon lands exactly on the attitude horizon and tracks it 1:1
(accounting for the 1.5× canvas scale). Raise maxPitch to 85.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-02 16:46:25 +02:00

198 lines
9.2 KiB
React
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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 };
}
// Place the synthetic horizon exactly where the PFD attitude horizon sits, so
// the 3D terrain and the 2D attitude agree. The attitude horizon is at 28% of
// the SVT box at level flight and moves PITCH_PX (9 px) per degree within the
// 706-px-tall box; the canvas is scaled 1.5× about that 28% line (to cover the
// corners when banked). We invert MapLibre's perspective to find the camera
// pitch that lands the flat horizon there. With the default vertical FOV of
// 36.87°, the focal-length/height ratio is 0.5/tan(fov/2) = 1.5, independent of
// resolution. Screen offset of the horizon above centre = f·tan(90°−pitch).
const CANVAS_SCALE = 1.5; // matches .svt-canvas CSS transform scale
const FOV_FH = 1.5; // 0.5 / tan(fov/2), fov = 36.87° (MapLibre default)
const HORIZON0 = (270 - 74) / 706; // attitude horizon as a fraction of the SVT box (≈0.2776)
const PX_PER_DEG = 9 / 706; // PITCH_PX / box height — displayed horizon travel per °
function cameraPitchForAircraft(aircraftPitchDeg) {
const dispFrac = HORIZON0 + PX_PER_DEG * aircraftPitchDeg; // where the horizon must appear
const rawFrac = HORIZON0 + (dispFrac - HORIZON0) / CANVAS_SCALE; // undo the 1.5× canvas scale
const t = (0.5 - rawFrac) / FOV_FH; // = tan(90°−pitch)
const pitch = 90 - (Math.atan(t) * 180) / Math.PI;
return Math.max(60, Math.min(85, pitch));
}
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: cameraPitchForAircraft(num(values.pitch)),
bearing: num(values.heading),
maxPitch: 85, // horizon placement needs ~82° at level, more when pitched up
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: cameraPitchForAircraft(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>
);
}