5b7cf13e9d
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>
198 lines
9.2 KiB
React
198 lines
9.2 KiB
React
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>
|
||
);
|
||
}
|