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 (
); }