diff --git a/static/css/settings.css b/static/css/settings.css index c717b3c..36f2987 100644 --- a/static/css/settings.css +++ b/static/css/settings.css @@ -479,6 +479,10 @@ filter: sepia(0.35) hue-rotate(185deg) saturate(1.75) brightness(1.06) contrast(1.05); } +.tile-layer-flir { + filter: grayscale(1) sepia(1) hue-rotate(-18deg) saturate(4.85) brightness(0.96) contrast(1.34); +} + /* Global Leaflet map theme: cyber overlay */ .leaflet-container.map-theme-cyber { position: relative; @@ -527,6 +531,55 @@ html.map-cyber-enabled .leaflet-container::after { background-size: 52px 52px, 52px 52px; } +/* Global Leaflet map theme: FLIR thermal overlay */ +.leaflet-container.map-theme-flir { + position: relative; + background: #090602; + isolation: isolate; +} + +.leaflet-container.map-theme-flir .leaflet-tile-pane { + filter: grayscale(1) sepia(1) hue-rotate(-18deg) saturate(4.85) brightness(0.96) contrast(1.34); + opacity: 1; +} + +/* Hard global fallback: enforce FLIR tint on all Leaflet tile images */ +html.map-flir-enabled .leaflet-container .leaflet-tile { + filter: grayscale(1) sepia(1) hue-rotate(-18deg) saturate(4.85) brightness(0.96) contrast(1.34) !important; +} + +/* Hard global fallback: thermal glow + scanline/grid overlay */ +html.map-flir-enabled .leaflet-container { + position: relative; + isolation: isolate; +} + +html.map-flir-enabled .leaflet-container::before { + content: ''; + position: absolute; + inset: 0; + pointer-events: none; + z-index: 620; + background: + radial-gradient(115% 90% at 50% 40%, rgba(255, 132, 28, 0.22), rgba(255, 132, 28, 0) 63%), + linear-gradient(180deg, rgba(14, 229, 255, 0.08) 0%, rgba(255, 96, 18, 0.15) 58%, rgba(255, 233, 128, 0.11) 100%); +} + +html.map-flir-enabled .leaflet-container::after { + content: ''; + position: absolute; + inset: 0; + pointer-events: none; + z-index: 621; + opacity: 0.32; + mix-blend-mode: screen; + background-image: + repeating-linear-gradient(0deg, rgba(255, 188, 92, 0.08) 0 1px, transparent 1px 3px), + linear-gradient(90deg, rgba(255, 141, 66, 0.12) 1px, transparent 1px), + linear-gradient(rgba(0, 240, 255, 0.07) 1px, transparent 1px); + background-size: 100% 3px, 68px 68px, 68px 68px; +} + /* Responsive */ @media (max-width: 960px) { .settings-tabs { diff --git a/static/images/globe/earth-dark.jpg b/static/images/globe/earth-dark.jpg new file mode 100644 index 0000000..222bd93 Binary files /dev/null and b/static/images/globe/earth-dark.jpg differ diff --git a/static/js/core/settings-manager.js b/static/js/core/settings-manager.js index f883130..676c588 100644 --- a/static/js/core/settings-manager.js +++ b/static/js/core/settings-manager.js @@ -33,6 +33,15 @@ const Settings = { mapTheme: 'cyber', options: {} }, + cartodb_dark_flir: { + url: 'https://cartodb-basemaps-{s}.global.ssl.fastly.net/dark_all/{z}/{x}/{y}.png', + attribution: '© OSM © CARTO', + subdomains: 'abcd', + mapTheme: 'flir', + options: { + className: 'tile-layer-flir' + } + }, cartodb_light: { url: 'https://cartodb-basemaps-{s}.global.ssl.fastly.net/light_all/{z}/{x}/{y}.png', attribution: '© OSM © CARTO', @@ -98,24 +107,16 @@ const Settings = { localStorage.setItem('intercept_map_theme_pref', pref); }, - /** - * Whether Cyber map theme should be considered active globally. - * @param {Object} [config] - * @returns {boolean} - */ - _isCyberThemeEnabled(config) { - const resolvedConfig = config || this.getTileConfig(); - return this._getMapThemeClass(resolvedConfig) === 'map-theme-cyber'; - }, - /** * Toggle root class used for hard global Leaflet theming. * @param {Object} [config] */ _syncRootMapThemeClass(config) { if (typeof document === 'undefined' || !document.documentElement) return; - const enabled = this._isCyberThemeEnabled(config); - document.documentElement.classList.toggle('map-cyber-enabled', enabled); + const resolvedConfig = config || this.getTileConfig(); + const themeClass = this._getMapThemeClass(resolvedConfig); + document.documentElement.classList.toggle('map-cyber-enabled', themeClass === 'map-theme-cyber'); + document.documentElement.classList.toggle('map-flir-enabled', themeClass === 'map-theme-flir'); }, /** @@ -350,6 +351,7 @@ const Settings = { _getMapThemeClass(config) { if (!config || !config.mapTheme) return null; if (config.mapTheme === 'cyber') return 'map-theme-cyber'; + if (config.mapTheme === 'flir') return 'map-theme-flir'; return null; }, @@ -373,7 +375,7 @@ const Settings = { container.style.background = ''; } - container.classList.remove('map-theme-cyber'); + container.classList.remove('map-theme-cyber', 'map-theme-flir'); const resolvedConfig = config || this.getTileConfig(); const themeClass = this._getMapThemeClass(resolvedConfig); @@ -381,17 +383,28 @@ const Settings = { container.classList.add(themeClass); - if (container.style) { - container.style.background = '#020813'; - } - if (tilePane && tilePane.style) { - tilePane.style.filter = 'sepia(0.74) hue-rotate(176deg) saturate(1.72) brightness(1.05) contrast(1.08)'; - tilePane.style.opacity = '1'; - tilePane.style.willChange = 'filter'; + if (themeClass === 'map-theme-cyber') { + if (container.style) { + container.style.background = '#020813'; + } + if (tilePane && tilePane.style) { + tilePane.style.filter = 'sepia(0.74) hue-rotate(176deg) saturate(1.72) brightness(1.05) contrast(1.08)'; + tilePane.style.opacity = '1'; + tilePane.style.willChange = 'filter'; + } + } else if (themeClass === 'map-theme-flir') { + if (container.style) { + container.style.background = '#090602'; + } + if (tilePane && tilePane.style) { + tilePane.style.filter = 'grayscale(1) sepia(1) hue-rotate(-18deg) saturate(4.85) brightness(0.96) contrast(1.34)'; + tilePane.style.opacity = '1'; + tilePane.style.willChange = 'filter'; + } } - // Grid/glow overlays are rendered via CSS pseudo elements on - // `html.map-cyber-enabled .leaflet-container` for consistent stacking. + // Map overlays are rendered via CSS pseudo elements on + // `html.map-*-enabled .leaflet-container` for consistent stacking. }, /** diff --git a/static/js/modes/websdr.js b/static/js/modes/websdr.js index 72ade80..280dbde 100644 --- a/static/js/modes/websdr.js +++ b/static/js/modes/websdr.js @@ -9,6 +9,20 @@ let websdrMarkers = []; let websdrReceivers = []; let websdrInitialized = false; let websdrSpyStationsLoaded = false; +let websdrMapType = null; +let websdrGlobe = null; +let websdrGlobePopup = null; +let websdrSelectedReceiverIndex = null; +let websdrGlobeScriptPromise = null; +let websdrResizeObserver = null; +let websdrResizeHooked = false; +let websdrGlobeFallbackNotified = false; + +const WEBSDR_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 WEBSDR_GLOBE_TEXTURE_URL = '/static/images/globe/earth-dark.jpg'; // KiwiSDR audio state let kiwiWebSocket = null; @@ -29,54 +43,39 @@ const KIWI_SAMPLE_RATE = 12000; async function initWebSDR() { if (websdrInitialized) { - if (websdrMap) { - setTimeout(() => websdrMap.invalidateSize(), 100); - } + setTimeout(invalidateWebSDRViewport, 100); return; } const mapEl = document.getElementById('websdrMap'); - if (!mapEl || typeof L === 'undefined') return; + if (!mapEl) return; - // Calculate minimum zoom so tiles fill the container vertically - const mapHeight = mapEl.clientHeight || 500; - const minZoom = Math.ceil(Math.log2(mapHeight / 256)); - - websdrMap = L.map('websdrMap', { - center: [20, 0], - zoom: Math.max(minZoom, 2), - minZoom: Math.max(minZoom, 2), - zoomControl: true, - maxBounds: [[-85, -360], [85, 360]], - maxBoundsViscosity: 1.0, - }); - - if (typeof Settings !== 'undefined' && Settings.createTileLayer) { - await Settings.init(); - Settings.createTileLayer().addTo(websdrMap); - Settings.registerMap(websdrMap); + const globeReady = await ensureWebsdrGlobeLibrary(); + if (globeReady && initWebsdrGlobe(mapEl)) { + websdrMapType = 'globe'; + } else if (typeof L !== 'undefined' && await initWebsdrLeaflet(mapEl)) { + websdrMapType = 'leaflet'; + if (!websdrGlobeFallbackNotified && typeof showNotification === 'function') { + showNotification('WebSDR', '3D globe unavailable, using fallback map'); + websdrGlobeFallbackNotified = true; + } } else { - L.tileLayer('https://{s}.basemaps.cartocdn.com/dark_all/{z}/{x}/{y}{r}.png', { - attribution: '© OpenStreetMap contributors © CARTO', - subdomains: 'abcd', - maxZoom: 19, - className: 'tile-layer-cyan', - }).addTo(websdrMap); + console.error('[WEBSDR] Unable to initialize globe or map renderer'); + return; } - // Match background to tile ocean color so any remaining edge is seamless - mapEl.style.background = '#1a1d29'; - websdrInitialized = true; if (!websdrSpyStationsLoaded) { loadSpyStationPresets(); } + setupWebsdrResizeHandling(mapEl); + if (websdrReceivers.length > 0) { + plotReceiversOnMap(websdrReceivers); + } [100, 300, 600, 1000].forEach(delay => { - setTimeout(() => { - if (websdrMap) websdrMap.invalidateSize(); - }, delay); + setTimeout(invalidateWebSDRViewport, delay); }); } @@ -94,6 +93,8 @@ function searchReceivers(refresh) { .then(data => { if (data.status === 'success') { websdrReceivers = data.receivers || []; + websdrSelectedReceiverIndex = null; + hideWebsdrGlobePopup(); renderReceiverList(websdrReceivers); plotReceiversOnMap(websdrReceivers); @@ -107,6 +108,11 @@ function searchReceivers(refresh) { // ============== MAP ============== function plotReceiversOnMap(receivers) { + if (websdrMapType === 'globe' && websdrGlobe) { + plotReceiversOnGlobe(receivers); + return; + } + if (!websdrMap) return; websdrMarkers.forEach(m => websdrMap.removeLayer(m)); @@ -144,6 +150,369 @@ function plotReceiversOnMap(receivers) { } } +async function ensureWebsdrGlobeLibrary() { + if (typeof window.Globe === 'function') return true; + if (!isWebglSupported()) return false; + + if (!websdrGlobeScriptPromise) { + websdrGlobeScriptPromise = WEBSDR_GLOBE_SCRIPT_URLS + .reduce( + (promise, src) => promise.then(() => loadWebsdrScript(src)), + Promise.resolve() + ) + .then(() => typeof window.Globe === 'function') + .catch((error) => { + console.warn('[WEBSDR] Failed to load globe scripts:', error); + return false; + }); + } + + const loaded = await websdrGlobeScriptPromise; + if (!loaded) { + websdrGlobeScriptPromise = null; + } + return loaded; +} + +function loadWebsdrScript(src) { + return new Promise((resolve, reject) => { + const selector = `script[data-websdr-src="${src}"]`; + const existing = document.querySelector(selector); + + 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.websdrSrc = 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 isWebglSupported() { + try { + const canvas = document.createElement('canvas'); + return !!(canvas.getContext('webgl') || canvas.getContext('experimental-webgl')); + } catch (_) { + return false; + } +} + +function initWebsdrGlobe(mapEl) { + if (typeof window.Globe !== 'function' || !isWebglSupported()) return false; + + mapEl.innerHTML = ''; + mapEl.style.background = 'radial-gradient(circle at 30% 20%, rgba(14, 42, 68, 0.9), rgba(4, 9, 16, 0.95) 58%, rgba(2, 4, 9, 0.98) 100%)'; + mapEl.style.cursor = 'grab'; + + websdrGlobe = window.Globe()(mapEl) + .backgroundColor('rgba(0,0,0,0)') + .globeImageUrl(WEBSDR_GLOBE_TEXTURE_URL) + .showAtmosphere(true) + .atmosphereColor('#3bb9ff') + .atmosphereAltitude(0.17) + .pointRadius('radius') + .pointAltitude('altitude') + .pointColor('color') + .pointsTransitionDuration(250) + .pointLabel(point => point.label || '') + .onPointHover(point => { + mapEl.style.cursor = point ? 'pointer' : 'grab'; + }) + .onPointClick((point, event) => { + if (!point) return; + showWebsdrGlobePopup(point, event); + }); + + const controls = websdrGlobe.controls(); + if (controls) { + controls.autoRotate = true; + controls.autoRotateSpeed = 0.25; + controls.enablePan = false; + controls.minDistance = 140; + controls.maxDistance = 380; + controls.rotateSpeed = 0.7; + controls.zoomSpeed = 0.8; + } + + ensureWebsdrGlobePopup(mapEl); + resizeWebsdrGlobe(); + return true; +} + +async function initWebsdrLeaflet(mapEl) { + if (typeof L === 'undefined') return false; + + mapEl.innerHTML = ''; + const mapHeight = mapEl.clientHeight || 500; + const minZoom = Math.ceil(Math.log2(mapHeight / 256)); + + websdrMap = L.map('websdrMap', { + center: [20, 0], + zoom: Math.max(minZoom, 2), + minZoom: Math.max(minZoom, 2), + zoomControl: true, + maxBounds: [[-85, -360], [85, 360]], + maxBoundsViscosity: 1.0, + }); + + if (typeof Settings !== 'undefined' && Settings.createTileLayer) { + await Settings.init(); + Settings.createTileLayer().addTo(websdrMap); + Settings.registerMap(websdrMap); + } else { + L.tileLayer('https://{s}.basemaps.cartocdn.com/dark_all/{z}/{x}/{y}{r}.png', { + attribution: '© OpenStreetMap contributors © CARTO', + subdomains: 'abcd', + maxZoom: 19, + className: 'tile-layer-cyan', + }).addTo(websdrMap); + } + + mapEl.style.background = '#1a1d29'; + return true; +} + +function setupWebsdrResizeHandling(mapEl) { + if (typeof ResizeObserver !== 'undefined') { + if (websdrResizeObserver) { + websdrResizeObserver.disconnect(); + } + websdrResizeObserver = new ResizeObserver(() => invalidateWebSDRViewport()); + websdrResizeObserver.observe(mapEl); + } + + if (!websdrResizeHooked) { + window.addEventListener('resize', invalidateWebSDRViewport); + window.addEventListener('orientationchange', () => setTimeout(invalidateWebSDRViewport, 120)); + websdrResizeHooked = true; + } +} + +function invalidateWebSDRViewport() { + if (websdrMapType === 'globe') { + resizeWebsdrGlobe(); + return; + } + if (websdrMap && typeof websdrMap.invalidateSize === 'function') { + websdrMap.invalidateSize({ pan: false, animate: false }); + } +} + +function resizeWebsdrGlobe() { + if (!websdrGlobe) return; + const mapEl = document.getElementById('websdrMap'); + if (!mapEl) return; + + const width = mapEl.clientWidth; + const height = mapEl.clientHeight; + if (!width || !height) return; + + websdrGlobe.width(width); + websdrGlobe.height(height); +} + +function plotReceiversOnGlobe(receivers) { + if (!websdrGlobe) return; + + const points = []; + receivers.forEach((rx, idx) => { + const lat = Number(rx.lat); + const lon = Number(rx.lon); + if (!Number.isFinite(lat) || !Number.isFinite(lon)) return; + + const selected = idx === websdrSelectedReceiverIndex; + points.push({ + lat: lat, + lng: lon, + receiverIndex: idx, + radius: selected ? 0.52 : 0.38, + altitude: selected ? 0.1 : 0.04, + color: selected ? '#00ff88' : (rx.available ? '#00d4ff' : '#5f6976'), + label: buildWebsdrPointLabel(rx, idx), + }); + }); + + websdrGlobe.pointsData(points); + + if (points.length > 0) { + if (websdrSelectedReceiverIndex != null) { + const selectedPoint = points.find(point => point.receiverIndex === websdrSelectedReceiverIndex); + if (selectedPoint) { + websdrGlobe.pointOfView({ lat: selectedPoint.lat, lng: selectedPoint.lng, altitude: 1.45 }, 900); + return; + } + } + + const center = computeWebsdrGlobeCenter(points); + websdrGlobe.pointOfView(center, 900); + } +} + +function computeWebsdrGlobeCenter(points) { + if (!points.length) return { lat: 20, lng: 0, altitude: 2.1 }; + + let x = 0; + let y = 0; + let z = 0; + points.forEach(point => { + const latRad = point.lat * Math.PI / 180; + const lonRad = point.lng * Math.PI / 180; + x += Math.cos(latRad) * Math.cos(lonRad); + y += Math.cos(latRad) * Math.sin(lonRad); + z += Math.sin(latRad); + }); + + const count = points.length; + x /= count; + y /= count; + z /= count; + + const hyp = Math.sqrt((x * x) + (y * y)); + const centerLat = Math.atan2(z, hyp) * 180 / Math.PI; + const centerLng = Math.atan2(y, x) * 180 / Math.PI; + + let meanAngularDistance = 0; + const centerLatRad = centerLat * Math.PI / 180; + const centerLngRad = centerLng * Math.PI / 180; + points.forEach(point => { + const latRad = point.lat * Math.PI / 180; + const lonRad = point.lng * Math.PI / 180; + const cosAngle = ( + (Math.sin(centerLatRad) * Math.sin(latRad)) + + (Math.cos(centerLatRad) * Math.cos(latRad) * Math.cos(lonRad - centerLngRad)) + ); + const safeCos = Math.max(-1, Math.min(1, cosAngle)); + meanAngularDistance += Math.acos(safeCos) * 180 / Math.PI; + }); + meanAngularDistance /= count; + + const altitude = Math.min(2.9, Math.max(1.35, 1.35 + (meanAngularDistance / 45))); + return { lat: centerLat, lng: centerLng, altitude: altitude }; +} + +function ensureWebsdrGlobePopup(mapEl) { + if (websdrGlobePopup) { + if (websdrGlobePopup.parentElement !== mapEl) { + mapEl.appendChild(websdrGlobePopup); + } + return; + } + + websdrGlobePopup = document.createElement('div'); + websdrGlobePopup.id = 'websdrGlobePopup'; + websdrGlobePopup.style.position = 'absolute'; + websdrGlobePopup.style.minWidth = '220px'; + websdrGlobePopup.style.maxWidth = '260px'; + websdrGlobePopup.style.padding = '10px'; + websdrGlobePopup.style.borderRadius = '8px'; + websdrGlobePopup.style.border = '1px solid rgba(0, 212, 255, 0.35)'; + websdrGlobePopup.style.background = 'rgba(5, 13, 20, 0.92)'; + websdrGlobePopup.style.backdropFilter = 'blur(4px)'; + websdrGlobePopup.style.boxShadow = '0 8px 24px rgba(0, 0, 0, 0.4)'; + websdrGlobePopup.style.color = 'var(--text-primary)'; + websdrGlobePopup.style.display = 'none'; + websdrGlobePopup.style.zIndex = '20'; + mapEl.appendChild(websdrGlobePopup); + + if (!mapEl.dataset.websdrPopupHooked) { + mapEl.addEventListener('click', (event) => { + if (!websdrGlobePopup || websdrGlobePopup.style.display === 'none') return; + if (event.target.closest('#websdrGlobePopup')) return; + hideWebsdrGlobePopup(); + }); + mapEl.dataset.websdrPopupHooked = 'true'; + } +} + +function showWebsdrGlobePopup(point, event) { + if (!websdrGlobePopup || !point || point.receiverIndex == null) return; + const rx = websdrReceivers[point.receiverIndex]; + if (!rx) return; + + const mapEl = document.getElementById('websdrMap'); + if (!mapEl) return; + + websdrSelectedReceiverIndex = point.receiverIndex; + renderReceiverList(websdrReceivers); + plotReceiversOnGlobe(websdrReceivers); + + websdrGlobePopup.innerHTML = ` +