diff --git a/static/css/modes/gps.css b/static/css/modes/gps.css index 22f40d9..92bf2fc 100644 --- a/static/css/modes/gps.css +++ b/static/css/modes/gps.css @@ -151,8 +151,17 @@ overflow: hidden; } +.gps-sky-globe { + position: absolute; + inset: 0; + width: 100%; + height: 100%; +} + #gpsSkyCanvas { - display: block; + position: absolute; + inset: 0; + display: none; width: 100%; height: 100%; cursor: grab; @@ -166,10 +175,20 @@ .gps-sky-overlay { position: absolute; inset: 0; + display: none; pointer-events: none; font-family: var(--font-mono); } +.gps-skyview-canvas-wrap.gps-sky-fallback .gps-sky-globe { + display: none; +} + +.gps-skyview-canvas-wrap.gps-sky-fallback #gpsSkyCanvas, +.gps-skyview-canvas-wrap.gps-sky-fallback .gps-sky-overlay { + display: block; +} + .gps-sky-label { position: absolute; transform: translate(-50%, -50%); diff --git a/static/js/modes/gps.js b/static/js/modes/gps.js index 0af071b..6ea23fa 100644 --- a/static/js/modes/gps.js +++ b/static/js/modes/gps.js @@ -12,19 +12,41 @@ const GPS = (function() { let themeObserver = null; let skyRenderer = null; let skyRendererInitAttempted = false; - - // Constellation color map - const CONST_COLORS = { - 'GPS': '#00d4ff', - 'GLONASS': '#00ff88', + let skyRendererInitPromise = null; + + // Constellation color map + const CONST_COLORS = { + 'GPS': '#00d4ff', + 'GLONASS': '#00ff88', 'Galileo': '#ff8800', 'BeiDou': '#ff4466', - 'SBAS': '#ffdd00', - 'QZSS': '#cc66ff', - }; - + 'SBAS': '#ffdd00', + 'QZSS': '#cc66ff', + }; + + const CONST_ALTITUDES = { + 'GPS': 0.28, + 'GLONASS': 0.27, + 'Galileo': 0.29, + 'BeiDou': 0.30, + 'SBAS': 0.34, + 'QZSS': 0.31, + }; + + const GPS_GLOBE_SCRIPT_URLS = [ + 'https://cdn.jsdelivr.net/npm/three@0.160.0/build/three.min.js', + 'https://cdn.jsdelivr.net/npm/globe.gl@2.33.1/dist/globe.gl.min.js', + ]; + const GPS_GLOBE_TEXTURE_URL = '/static/images/globe/earth-dark.jpg'; + function init() { - initSkyRenderer(); + const initPromise = initSkyRenderer(); + if (initPromise && typeof initPromise.then === 'function') { + initPromise.then(() => { + if (lastSky) drawSkyView(lastSky.satellites || []); + else drawEmptySkyView(); + }).catch(() => {}); + } drawEmptySkyView(); if (!connected) connect(); @@ -48,20 +70,370 @@ const GPS = (function() { } function initSkyRenderer() { - if (skyRendererInitAttempted) return; + if (skyRendererInitPromise) return skyRendererInitPromise; skyRendererInitAttempted = true; - const canvas = document.getElementById('gpsSkyCanvas'); - if (!canvas) return; + skyRendererInitPromise = (async function() { + const globeContainer = document.getElementById('gpsSkyGlobe'); + if (globeContainer) { + try { + const globeRenderer = await createGlobeSkyRenderer(globeContainer); + if (globeRenderer) { + setSkyCanvasFallbackMode(false); + skyRenderer = globeRenderer; + return skyRenderer; + } + } catch (err) { + console.warn('GPS globe renderer failed, falling back to canvas renderer', err); + } + } - const overlay = document.getElementById('gpsSkyOverlay'); - try { - skyRenderer = createWebGlSkyRenderer(canvas, overlay); - } catch (err) { - skyRenderer = null; - console.warn('GPS sky WebGL renderer failed, falling back to 2D', err); + setSkyCanvasFallbackMode(true); + + const canvas = document.getElementById('gpsSkyCanvas'); + if (!canvas) return null; + + const overlay = document.getElementById('gpsSkyOverlay'); + try { + skyRenderer = createWebGlSkyRenderer(canvas, overlay); + return skyRenderer; + } catch (err) { + skyRenderer = null; + console.warn('GPS sky WebGL renderer failed, falling back to 2D', err); + return null; + } + })(); + + return skyRendererInitPromise; + } + + function setSkyCanvasFallbackMode(enabled) { + const wrap = document.getElementById('gpsSkyViewWrap'); + if (wrap) { + wrap.classList.toggle('gps-sky-fallback', !!enabled); } } + + function isSkyCanvasFallbackEnabled() { + const wrap = document.getElementById('gpsSkyViewWrap'); + return !wrap || wrap.classList.contains('gps-sky-fallback'); + } + + function getObserverCoords() { + const posLat = Number(lastPosition && lastPosition.latitude); + const posLon = Number(lastPosition && lastPosition.longitude); + if (Number.isFinite(posLat) && Number.isFinite(posLon)) { + return { lat: posLat, lon: normalizeLon(posLon) }; + } + + if (typeof observerLocation === 'object' && observerLocation) { + const obsLat = Number(observerLocation.lat); + const obsLon = Number(observerLocation.lon); + if (Number.isFinite(obsLat) && Number.isFinite(obsLon)) { + return { lat: obsLat, lon: normalizeLon(obsLon) }; + } + } + + return null; + } + + async function ensureGpsGlobeLibrary() { + if (typeof window.Globe === 'function') return true; + + const webglSupportFn = (typeof isWebglSupported === 'function') ? isWebglSupported : localWebglSupportCheck; + if (!webglSupportFn()) return false; + + if (typeof ensureWebsdrGlobeLibrary === 'function') { + try { + const ready = await ensureWebsdrGlobeLibrary(); + if (ready && typeof window.Globe === 'function') return true; + } catch (_) {} + } + + for (const src of GPS_GLOBE_SCRIPT_URLS) { + await loadGpsGlobeScript(src); + } + return typeof window.Globe === 'function'; + } + + function loadGpsGlobeScript(src) { + return new Promise((resolve, reject) => { + const existing = document.querySelector( + `script[data-websdr-src="${src}"], script[data-gps-globe-src="${src}"]` + ); + + if (existing) { + if (existing.dataset.loaded === 'true') { + resolve(); + return; + } + if (existing.dataset.failed === 'true') { + existing.remove(); + } else { + existing.addEventListener('load', () => resolve(), { once: true }); + existing.addEventListener('error', () => reject(new Error(`Failed to load ${src}`)), { once: true }); + return; + } + } + + const script = document.createElement('script'); + script.src = src; + script.async = true; + script.crossOrigin = 'anonymous'; + script.dataset.gpsGlobeSrc = src; + script.onload = () => { + script.dataset.loaded = 'true'; + resolve(); + }; + script.onerror = () => { + script.dataset.failed = 'true'; + reject(new Error(`Failed to load ${src}`)); + }; + document.head.appendChild(script); + }); + } + + function localWebglSupportCheck() { + try { + const canvas = document.createElement('canvas'); + return !!(canvas.getContext('webgl') || canvas.getContext('experimental-webgl')); + } catch (_) { + return false; + } + } + + async function createGlobeSkyRenderer(container) { + const ready = await ensureGpsGlobeLibrary(); + if (!ready || typeof window.Globe !== 'function') return null; + + let layoutAttempts = 0; + while ((!container.clientWidth || !container.clientHeight) && layoutAttempts < 4) { + await new Promise(resolve => requestAnimationFrame(resolve)); + layoutAttempts += 1; + } + if (!container.clientWidth || !container.clientHeight) return null; + + container.innerHTML = ''; + container.style.background = 'radial-gradient(circle at 32% 18%, rgba(16, 45, 70, 0.92), rgba(4, 9, 16, 0.96) 58%, rgba(2, 4, 9, 0.99) 100%)'; + container.style.cursor = 'grab'; + + const globe = window.Globe()(container) + .backgroundColor('rgba(0,0,0,0)') + .globeImageUrl(GPS_GLOBE_TEXTURE_URL) + .showAtmosphere(true) + .atmosphereColor('#3bb9ff') + .atmosphereAltitude(0.17) + .pointRadius('radius') + .pointAltitude('altitude') + .pointColor('color') + .pointLabel(point => point.label || '') + .pointsTransitionDuration(260) + .arcColor('color') + .arcAltitude('altitude') + .arcStroke('stroke') + .arcDashLength('dashLength') + .arcDashGap('dashGap') + .arcDashInitialGap('dashInitialGap') + .arcDashAnimateTime('dashAnimateTime'); + + const controls = globe.controls(); + if (controls) { + controls.autoRotate = true; + controls.autoRotateSpeed = 0.22; + controls.enablePan = false; + controls.minDistance = 130; + controls.maxDistance = 420; + controls.rotateSpeed = 0.8; + controls.zoomSpeed = 0.8; + } + + let destroyed = false; + let lastSatellites = []; + let hasInitialView = false; + const resizeObserver = (typeof ResizeObserver !== 'undefined') + ? new ResizeObserver(() => resizeGlobe()) + : null; + + if (resizeObserver) resizeObserver.observe(container); + + function resizeGlobe() { + if (destroyed) return; + const width = container.clientWidth; + const height = container.clientHeight; + if (!width || !height) return; + globe.width(width); + globe.height(height); + } + + function renderGlobe() { + if (destroyed) return; + resizeGlobe(); + + const observer = getObserverCoords(); + const points = []; + const arcs = []; + + if (observer) { + points.push({ + lat: observer.lat, + lng: observer.lon, + altitude: 0.012, + radius: 0.34, + color: '#ffffff', + label: '