/* RF Heatmap — GPS + signal strength Leaflet heatmap */ const RFHeatmap = (function () { 'use strict'; let _map = null; let _heatLayer = null; let _gpsSource = null; let _sigSource = null; let _heatPoints = []; let _isRecording = false; let _lastLat = null, _lastLng = null; let _minDist = 5; let _source = 'wifi'; let _gpsPos = null; let _lastSignal = null; let _active = false; let _ownedSource = false; // true if heatmap started the source itself const RSSI_RANGES = { wifi: { min: -90, max: -30 }, bluetooth: { min: -100, max: -40 }, scanner: { min: -120, max: -20 }, }; function _norm(val, src) { const r = RSSI_RANGES[src] || RSSI_RANGES.wifi; return Math.max(0, Math.min(1, (val - r.min) / (r.max - r.min))); } function _haversineM(lat1, lng1, lat2, lng2) { const R = 6371000; const dLat = (lat2 - lat1) * Math.PI / 180; const dLng = (lng2 - lng1) * Math.PI / 180; const a = Math.sin(dLat / 2) ** 2 + Math.cos(lat1 * Math.PI / 180) * Math.cos(lat2 * Math.PI / 180) * Math.sin(dLng / 2) ** 2; return R * 2 * Math.atan2(Math.sqrt(a), Math.sqrt(1 - a)); } function _ensureLeafletHeat(cb) { if (window.L && L.heatLayer) { cb(); return; } const s = document.createElement('script'); s.src = '/static/js/vendor/leaflet-heat.js'; s.onload = cb; s.onerror = () => console.warn('RF Heatmap: leaflet-heat.js failed to load'); document.head.appendChild(s); } function _initMap() { if (_map) return; const el = document.getElementById('rfheatmapMapEl'); if (!el) return; // Defer map creation until container has non-zero dimensions (prevents leaflet-heat IndexSizeError) if (el.offsetWidth === 0 || el.offsetHeight === 0) { setTimeout(_initMap, 200); return; } const fallback = _getFallbackPos(); const lat = _gpsPos ? _gpsPos.lat : (fallback ? fallback.lat : 37.7749); const lng = _gpsPos ? _gpsPos.lng : (fallback ? fallback.lng : -122.4194); _map = L.map(el, { zoomControl: true }).setView([lat, lng], 16); L.tileLayer('https://{s}.basemaps.cartocdn.com/dark_all/{z}/{x}/{y}{r}.png', { attribution: '© OpenStreetMap contributors © CARTO', subdomains: 'abcd', maxZoom: 20, }).addTo(_map); _heatLayer = L.heatLayer([], { radius: 25, blur: 15, maxZoom: 17 }).addTo(_map); } function _startGPS() { if (_gpsSource) { _gpsSource.close(); _gpsSource = null; } _gpsSource = new EventSource('/gps/stream'); _gpsSource.onmessage = (ev) => { try { const d = JSON.parse(ev.data); if (d.lat && d.lng && d.fix) { _gpsPos = { lat: parseFloat(d.lat), lng: parseFloat(d.lng) }; _updateGpsPill(true, _gpsPos.lat, _gpsPos.lng); if (_map) _map.setView([_gpsPos.lat, _gpsPos.lng], _map.getZoom(), { animate: false }); } else { _updateGpsPill(false); } } catch (_) {} }; _gpsSource.onerror = () => _updateGpsPill(false); } function _updateGpsPill(fix, lat, lng) { const pill = document.getElementById('rfhmGpsPill'); if (!pill) return; if (fix && lat !== undefined) { pill.textContent = `${lat.toFixed(5)}, ${lng.toFixed(5)}`; pill.style.color = 'var(--accent-green, #00ff88)'; } else { const fallback = _getFallbackPos(); pill.textContent = fallback ? 'No Fix (using fallback)' : 'No Fix'; pill.style.color = fallback ? 'var(--accent-yellow, #f59e0b)' : 'var(--text-dim, #555)'; } } function _startSignalStream() { if (_sigSource) { _sigSource.close(); _sigSource = null; } let url; if (_source === 'wifi') url = '/wifi/stream'; else if (_source === 'bluetooth') url = '/api/bluetooth/stream'; else url = '/listening/scanner/stream'; _sigSource = new EventSource(url); _sigSource.onmessage = (ev) => { try { const d = JSON.parse(ev.data); let rssi = null; if (_source === 'wifi') rssi = d.signal_level ?? d.signal ?? null; else if (_source === 'bluetooth') rssi = d.rssi ?? null; else rssi = d.power_level ?? d.power ?? null; if (rssi !== null) { _lastSignal = parseFloat(rssi); _updateSignalDisplay(_lastSignal); } _maybeSample(); } catch (_) {} }; } function _maybeSample() { if (!_isRecording || _lastSignal === null) return; if (!_gpsPos) { const fb = _getFallbackPos(); if (fb) _gpsPos = fb; else return; } const { lat, lng } = _gpsPos; if (_lastLat !== null) { const dist = _haversineM(_lastLat, _lastLng, lat, lng); if (dist < _minDist) return; } const intensity = _norm(_lastSignal, _source); _heatPoints.push([lat, lng, intensity]); _lastLat = lat; _lastLng = lng; if (_heatLayer) { const el = document.getElementById('rfheatmapMapEl'); if (el && el.offsetWidth > 0 && el.offsetHeight > 0) _heatLayer.setLatLngs(_heatPoints); } _updateCount(); } function _updateCount() { const el = document.getElementById('rfhmPointCount'); if (el) el.textContent = _heatPoints.length; } function _updateSignalDisplay(rssi) { const valEl = document.getElementById('rfhmLiveSignal'); const barEl = document.getElementById('rfhmSignalBar'); const statusEl = document.getElementById('rfhmSignalStatus'); if (!valEl) return; valEl.textContent = rssi !== null ? `${rssi.toFixed(1)} dBm` : '— dBm'; if (rssi !== null) { // Normalise to 0–100% for the bar const pct = Math.round(_norm(rssi, _source) * 100); if (barEl) barEl.style.width = pct + '%'; // Colour the value by strength let color, label; if (pct >= 66) { color = 'var(--accent-green, #00ff88)'; label = 'Strong'; } else if (pct >= 33) { color = 'var(--accent-cyan, #4aa3ff)'; label = 'Moderate'; } else { color = '#f59e0b'; label = 'Weak'; } valEl.style.color = color; if (barEl) barEl.style.background = color; if (statusEl) { statusEl.textContent = _isRecording ? `${label} — recording point every ${_minDist}m` : `${label} — press Start Recording to begin`; } } else { if (barEl) barEl.style.width = '0%'; valEl.style.color = 'var(--text-dim)'; if (statusEl) statusEl.textContent = 'No signal data received yet'; } } function setSource(src) { _source = src; if (_active) _startSignalStream(); } function setMinDist(m) { _minDist = m; } function startRecording() { _isRecording = true; _lastLat = null; _lastLng = null; const startBtn = document.getElementById('rfhmRecordBtn'); const stopBtn = document.getElementById('rfhmStopBtn'); if (startBtn) startBtn.style.display = 'none'; if (stopBtn) { stopBtn.style.display = ''; stopBtn.classList.add('rfhm-recording-pulse'); } } function stopRecording() { _isRecording = false; const startBtn = document.getElementById('rfhmRecordBtn'); const stopBtn = document.getElementById('rfhmStopBtn'); if (startBtn) startBtn.style.display = ''; if (stopBtn) { stopBtn.style.display = 'none'; stopBtn.classList.remove('rfhm-recording-pulse'); } } function clearPoints() { _heatPoints = []; if (_heatLayer) { const el = document.getElementById('rfheatmapMapEl'); if (el && el.offsetWidth > 0 && el.offsetHeight > 0) _heatLayer.setLatLngs([]); } _updateCount(); } function exportGeoJSON() { const features = _heatPoints.map(([lat, lng, intensity]) => ({ type: 'Feature', geometry: { type: 'Point', coordinates: [lng, lat] }, properties: { intensity, source: _source }, })); const geojson = { type: 'FeatureCollection', features }; const blob = new Blob([JSON.stringify(geojson, null, 2)], { type: 'application/json' }); const a = document.createElement('a'); a.href = URL.createObjectURL(blob); a.download = `rf_heatmap_${Date.now()}.geojson`; a.click(); } function invalidateMap() { if (!_map) return; const el = document.getElementById('rfheatmapMapEl'); if (el && el.offsetWidth > 0 && el.offsetHeight > 0) { _map.invalidateSize(); } } // ── Source lifecycle (start / stop / status) ────────────────────── async function _checkSourceStatus() { const src = _source; let running = false; let detail = null; try { if (src === 'wifi') { const r = await fetch('/wifi/v2/scan/status'); if (r.ok) { const d = await r.json(); running = !!d.is_scanning; detail = d.interface || null; } } else if (src === 'bluetooth') { const r = await fetch('/api/bluetooth/scan/status'); if (r.ok) { const d = await r.json(); running = !!d.is_scanning; } } else if (src === 'scanner') { const r = await fetch('/listening/scanner/status'); if (r.ok) { const d = await r.json(); running = !!d.running; } } } catch (_) {} return { running, detail }; } async function startSource() { const src = _source; const btn = document.getElementById('rfhmSourceStartBtn'); const status = document.getElementById('rfhmSourceStatus'); if (btn) { btn.disabled = true; btn.textContent = 'Starting…'; } try { let res; if (src === 'wifi') { // Try to find a monitor interface from the WiFi status first let iface = null; try { const st = await fetch('/wifi/v2/scan/status'); if (st.ok) { const d = await st.json(); iface = d.interface || null; } } catch (_) {} if (!iface) { // Ask the user to enter an interface name const entered = prompt('Enter your monitor-mode WiFi interface name (e.g. wlan0mon):'); if (!entered) { _updateSourceStatusUI(); return; } iface = entered.trim(); } res = await fetch('/wifi/v2/scan/start', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ interface: iface }) }); } else if (src === 'bluetooth') { res = await fetch('/api/bluetooth/scan/start', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ mode: 'auto' }) }); } else if (src === 'scanner') { const deviceVal = document.getElementById('rfhmDevice')?.value || 'rtlsdr:0'; const [sdrType, idxStr] = deviceVal.includes(':') ? deviceVal.split(':') : ['rtlsdr', '0']; res = await fetch('/listening/scanner/start', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ start_freq: 88, end_freq: 108, sdr_type: sdrType, device: parseInt(idxStr) || 0 }) }); } if (res && res.ok) { _ownedSource = true; _startSignalStream(); } } catch (_) {} await _updateSourceStatusUI(); } async function stopSource() { if (!_ownedSource) return; try { if (_source === 'wifi') await fetch('/wifi/v2/scan/stop', { method: 'POST' }); else if (_source === 'bluetooth') await fetch('/api/bluetooth/scan/stop', { method: 'POST' }); else if (_source === 'scanner') await fetch('/listening/scanner/stop', { method: 'POST' }); } catch (_) {} _ownedSource = false; await _updateSourceStatusUI(); } async function _updateSourceStatusUI() { const { running, detail } = await _checkSourceStatus(); const row = document.getElementById('rfhmSourceStatusRow'); const dotEl = document.getElementById('rfhmSourceDot'); const textEl = document.getElementById('rfhmSourceStatusText'); const startB = document.getElementById('rfhmSourceStartBtn'); const stopB = document.getElementById('rfhmSourceStopBtn'); if (!row) return; const SOURCE_NAMES = { wifi: 'WiFi Scanner', bluetooth: 'Bluetooth Scanner', scanner: 'SDR Scanner' }; const name = SOURCE_NAMES[_source] || _source; if (dotEl) dotEl.style.background = running ? 'var(--accent-green)' : 'rgba(255,255,255,0.2)'; if (textEl) textEl.textContent = running ? `${name} running${detail ? ' · ' + detail : ''}` : `${name} not running`; if (startB) { startB.style.display = running ? 'none' : ''; startB.disabled = false; startB.textContent = `Start ${name}`; } if (stopB) stopB.style.display = (running && _ownedSource) ? '' : 'none'; // Auto-subscribe to stream if source just became running if (running && !_sigSource) _startSignalStream(); } const SOURCE_HINTS = { wifi: 'Walk with your device — stronger WiFi signals are plotted brighter on the map.', bluetooth: 'Walk near Bluetooth devices — signal strength is mapped by RSSI.', scanner: 'SDR scanner power levels are mapped by GPS position. Start the Listening Post scanner first.', }; function onSourceChange() { const src = document.getElementById('rfhmSource')?.value || 'wifi'; const hint = document.getElementById('rfhmSourceHint'); const dg = document.getElementById('rfhmDeviceGroup'); if (hint) hint.textContent = SOURCE_HINTS[src] || ''; if (dg) dg.style.display = src === 'scanner' ? '' : 'none'; _lastSignal = null; _ownedSource = false; _updateSignalDisplay(null); _updateSourceStatusUI(); // Re-subscribe to correct stream if (_sigSource) { _sigSource.close(); _sigSource = null; } _startSignalStream(); } function _loadDevices() { const sel = document.getElementById('rfhmDevice'); if (!sel) return; fetch('/devices').then(r => r.json()).then(devices => { if (!devices || devices.length === 0) { sel.innerHTML = ''; return; } sel.innerHTML = devices.map(d => { const label = d.serial ? `${d.name} [${d.serial}]` : d.name; return ``; }).join(''); }).catch(() => { sel.innerHTML = ''; }); } function _getFallbackPos() { // Try observer location from localStorage (shared across all map modes) try { const stored = localStorage.getItem('observerLocation'); if (stored) { const p = JSON.parse(stored); if (p && typeof p.lat === 'number' && typeof p.lon === 'number') { return { lat: p.lat, lng: p.lon }; } } } catch (_) {} // Try manual coord inputs const lat = parseFloat(document.getElementById('rfhmManualLat')?.value); const lng = parseFloat(document.getElementById('rfhmManualLon')?.value); if (!isNaN(lat) && !isNaN(lng)) return { lat, lng }; return null; } function setManualCoords() { const lat = parseFloat(document.getElementById('rfhmManualLat')?.value); const lng = parseFloat(document.getElementById('rfhmManualLon')?.value); if (!isNaN(lat) && !isNaN(lng) && !_gpsPos && _map) { _map.setView([lat, lng], _map.getZoom(), { animate: false }); } } function useObserverLocation() { try { const stored = localStorage.getItem('observerLocation'); if (stored) { const p = JSON.parse(stored); if (p && typeof p.lat === 'number' && typeof p.lon === 'number') { const latEl = document.getElementById('rfhmManualLat'); const lonEl = document.getElementById('rfhmManualLon'); if (latEl) latEl.value = p.lat.toFixed(5); if (lonEl) lonEl.value = p.lon.toFixed(5); if (_map) _map.setView([p.lat, p.lon], _map.getZoom(), { animate: true }); return; } } } catch (_) {} } function init() { _active = true; _loadDevices(); onSourceChange(); // Pre-fill manual coords from observer location if available const fallback = _getFallbackPos(); if (fallback) { const latEl = document.getElementById('rfhmManualLat'); const lonEl = document.getElementById('rfhmManualLon'); if (latEl && !latEl.value) latEl.value = fallback.lat.toFixed(5); if (lonEl && !lonEl.value) lonEl.value = fallback.lng.toFixed(5); } _updateSignalDisplay(null); _updateSourceStatusUI(); _ensureLeafletHeat(() => { setTimeout(() => { _initMap(); _startGPS(); _startSignalStream(); }, 50); }); } function destroy() { _active = false; if (_isRecording) stopRecording(); if (_ownedSource) stopSource(); if (_gpsSource) { _gpsSource.close(); _gpsSource = null; } if (_sigSource) { _sigSource.close(); _sigSource = null; } } return { init, destroy, setSource, setMinDist, startRecording, stopRecording, clearPoints, exportGeoJSON, invalidateMap, onSourceChange, setManualCoords, useObserverLocation, startSource, stopSource }; })(); window.RFHeatmap = RFHeatmap;