/** * RF Mapper - Main Application JavaScript */ // Global state let currentView = '3d'; let scanData = null; let map = null; let markers = []; let filters = { wifi: false, bluetooth: true }; // Device trails state - stores trail data per device let deviceTrails = {}; // { deviceId: { type, name, points: [{ timestamp, distance, lat, lon }, ...] } } // Device manual positions - loaded from database let manualPositions = {}; // { deviceId: { lat_offset, lon_offset } } // Auto-scan state let autoScanEnabled = false; let autoScanPollInterval = null; // Real-time BT tracking state let liveTrackingEnabled = false; let liveTrackingInterval = null; const LIVE_TRACKING_INTERVAL_MS = 4000; // 4 seconds // Statistical movement detection const SAMPLE_HISTORY_SIZE = 5; // Number of samples to keep for averaging const MOVEMENT_THRESHOLD = 1.5; // meters - movement must exceed this + stddev margin const MIN_SAMPLES_FOR_MOVEMENT = 3; // Need at least this many samples before detecting movement // Store distance history per device: { address: { samples: [], timestamps: [] } } let deviceDistanceHistory = {}; // Track consecutive missed detections per device: { address: missCount } let deviceMissCount = {}; const MAX_MISSED_SCANS = 5; // Remove device after this many consecutive misses (~20s with 4s interval) // Calculate mean of array function mean(arr) { if (arr.length === 0) return 0; return arr.reduce((a, b) => a + b, 0) / arr.length; } // Calculate standard deviation function stddev(arr) { if (arr.length < 2) return 0; const avg = mean(arr); const squareDiffs = arr.map(x => Math.pow(x - avg, 2)); return Math.sqrt(mean(squareDiffs)); } // Check if device is moving based on statistical analysis function isDeviceMoving(address, newDistance) { // Initialize history if needed if (!deviceDistanceHistory[address]) { deviceDistanceHistory[address] = { samples: [], timestamps: [] }; } const history = deviceDistanceHistory[address]; const now = Date.now(); // Add new sample history.samples.push(newDistance); history.timestamps.push(now); // Remove old samples (keep last SAMPLE_HISTORY_SIZE) while (history.samples.length > SAMPLE_HISTORY_SIZE) { history.samples.shift(); history.timestamps.shift(); } // Need minimum samples to determine movement if (history.samples.length < MIN_SAMPLES_FOR_MOVEMENT) { return false; } // Calculate statistics from older samples (exclude the newest one) const olderSamples = history.samples.slice(0, -1); const avg = mean(olderSamples); const sd = stddev(olderSamples); // Movement threshold = base threshold + 2 standard deviations (95% confidence) const dynamicThreshold = MOVEMENT_THRESHOLD + (2 * sd); // Check if current reading deviates significantly from the average const deviation = Math.abs(newDistance - avg); const isMoving = deviation > dynamicThreshold; // Debug log for significant movements if (isMoving) { console.log(`[Movement] ${address}: dist=${newDistance.toFixed(2)}m, avg=${avg.toFixed(2)}m, sd=${sd.toFixed(2)}, threshold=${dynamicThreshold.toFixed(2)}m`); } return isMoving; } // Device positions for hit detection (radar view) let devicePositions = []; // Currently selected device let selectedDevice = null; // 3D Map state let map3d = null; let map3dMarkers = []; let map3dDeviceData = {}; // Store device info for popup display let currentFloor = 'all'; // Will be set to building.currentFloor on init let map3dInitialized = false; let map3dLoaded = false; let openPopupDeviceId = null; // Track which device popup is open // Initialize on DOM ready document.addEventListener('DOMContentLoaded', () => { initMap(); initRadar(); initFloorSelector(); loadLatestScan(); loadAutoScanStatus(); loadDevicePositions(); // Load saved manual positions // Initialize 3D map as default view setTimeout(() => { init3DMap(); map3dInitialized = true; }, 100); // Start BT live tracking by default after a short delay setTimeout(() => { startLiveTracking(); console.log('BT live tracking auto-started'); }, 2000); }); // Toggle filter function toggleFilter(type) { filters[type] = !filters[type]; const btn = document.getElementById(`filter-${type === 'wifi' ? 'wifi' : 'bt'}`); btn.classList.toggle('inactive', !filters[type]); drawRadar(); updateMapMarkers(); update3DMarkers(); } // Initialize Leaflet map function initMap() { const lat = APP_CONFIG.defaultLat; const lon = APP_CONFIG.defaultLon; map = L.map('leaflet-map', { minZoom: 2, maxZoom: 19 }).setView([lat, lon], 5); L.tileLayer('https://{s}.tile.openstreetmap.org/{z}/{x}/{y}.png', { attribution: '© OpenStreetMap contributors' }).addTo(map); } // Initialize radar canvas function initRadar() { const canvas = document.getElementById('radar-canvas'); resizeRadar(); window.addEventListener('resize', resizeRadar); // Add click handler for radar canvas.addEventListener('click', handleRadarClick); // Add mousemove handler for hover tooltips canvas.addEventListener('mousemove', handleRadarHover); canvas.addEventListener('mouseleave', hideRadarTooltip); // Create tooltip element const tooltip = document.createElement('div'); tooltip.id = 'radar-tooltip'; tooltip.className = 'radar-tooltip'; canvas.parentElement.appendChild(tooltip); // Click outside to close detail panel document.addEventListener('click', (e) => { const panel = document.getElementById('device-detail-panel'); if (!panel.contains(e.target) && !e.target.closest('.view-25d-device') && !e.target.closest('#radar-canvas')) { closeDetailPanel(); } }); } function resizeRadar() { const canvas = document.getElementById('radar-canvas'); const container = canvas.parentElement; canvas.width = container.clientWidth; canvas.height = container.clientHeight; if (scanData) drawRadar(); } // View toggle function setView(view) { currentView = view; document.getElementById('btn-radar').classList.toggle('active', view === 'radar'); document.getElementById('btn-map').classList.toggle('active', view === 'map'); document.getElementById('btn-3d').classList.toggle('active', view === '3d'); document.getElementById('radar-canvas').classList.toggle('active', view === 'radar'); document.getElementById('leaflet-map').classList.toggle('hidden', view !== 'map'); document.getElementById('map-3d').classList.toggle('hidden', view !== '3d'); document.getElementById('map-3d').classList.toggle('active', view === '3d'); // Close detail panel when switching views closeDetailPanel(); if (view === 'map') { setTimeout(() => map.invalidateSize(), 100); } else if (view === '3d') { setTimeout(() => { if (!map3dInitialized) { init3DMap(); map3dInitialized = true; } else if (map3d) { map3d.resize(); update3DMarkers(); } }, 100); } else { drawRadar(); } } // Load latest scan async function loadLatestScan() { try { const response = await fetch('/api/latest'); if (response.ok) { scanData = await response.json(); updateUI(); } else { document.getElementById('wifi-list').innerHTML = '
No scans yet. Click "New Scan" to start.
'; document.getElementById('bt-list').innerHTML = ''; } } catch (error) { console.error('Error loading scan:', error); } } // Trigger new scan async function triggerScan() { const btn = document.getElementById('scan-btn'); const status = document.getElementById('scan-status'); btn.disabled = true; status.textContent = 'Scanning...'; try { const lat = parseFloat(document.getElementById('lat-input').value); const lon = parseFloat(document.getElementById('lon-input').value); const response = await fetch('/api/scan', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ location: 'web_scan', lat: lat, lon: lon, scan_wifi: filters.wifi, scan_bluetooth: filters.bluetooth }) }); if (response.ok) { scanData = await response.json(); updateUI(); status.textContent = `Scanned at ${new Date().toLocaleTimeString()}`; } else { status.textContent = 'Scan failed'; } } catch (error) { console.error('Scan error:', error); status.textContent = 'Scan error'; } btn.disabled = false; } // Update UI with scan data function updateUI() { if (!scanData) return; const wifi = scanData.wifi_networks || []; const bt = scanData.bluetooth_devices || []; // Update counts document.getElementById('wifi-count').textContent = wifi.length; document.getElementById('bt-count').textContent = bt.length; document.getElementById('wifi-list-count').textContent = wifi.length; document.getElementById('bt-list-count').textContent = bt.length; // Calculate stats if (wifi.length > 0) { const avgSignal = Math.round(wifi.reduce((a, b) => a + b.rssi, 0) / wifi.length); document.getElementById('avg-signal').textContent = avgSignal + ' dB'; const allDevices = [...wifi, ...bt]; const nearest = Math.min(...allDevices.map(d => d.estimated_distance_m)); document.getElementById('nearest').textContent = nearest.toFixed(1); } // Update WiFi list const wifiList = document.getElementById('wifi-list'); wifiList.innerHTML = wifi .sort((a, b) => b.rssi - a.rssi) .slice(0, 15) .map(net => `
${escapeHtml(net.ssid)}
${escapeHtml(net.manufacturer)}
${net.rssi} dBm ~${net.estimated_distance_m}m ${signalBar(net.rssi)}
`).join(''); // Update Bluetooth list const btList = document.getElementById('bt-list'); btList.innerHTML = bt .sort((a, b) => b.rssi - a.rssi) .slice(0, 15) .map(dev => `
${escapeHtml(dev.name)}
${escapeHtml(dev.manufacturer)} - ${escapeHtml(dev.device_type)}
${dev.rssi} dBm ~${dev.estimated_distance_m}m ${signalBar(dev.rssi)}
`).join(''); // Update visualizations drawRadar(); updateMapMarkers(); update3DMarkers(); updateFloorSelector(); } // Signal strength bar function signalBar(rssi) { const bars = 5; const strength = Math.max(0, Math.min(bars, Math.floor((rssi + 100) / 15))); let cls = ''; if (rssi < -80) cls = 'weak'; else if (rssi < -65) cls = 'fair'; let html = `
`; for (let i = 0; i < bars; i++) { const h = 4 + i * 2; html += ``; } html += '
'; return html; } // Draw radar visualization function drawRadar() { const canvas = document.getElementById('radar-canvas'); const ctx = canvas.getContext('2d'); const w = canvas.width; const h = canvas.height; const cx = w / 2; const cy = h / 2; const maxRadius = Math.min(w, h) / 2 - 40; const maxDist = APP_CONFIG.radar.maxDistance; // Clear device positions for hit detection devicePositions = []; // Clear ctx.fillStyle = '#0a0a1a'; ctx.fillRect(0, 0, w, h); // Draw grid circles (distance rings) ctx.strokeStyle = '#1a3a1a'; ctx.lineWidth = 1; APP_CONFIG.radar.distances.forEach((dist) => { const r = (dist / maxDist) * maxRadius; ctx.beginPath(); ctx.arc(cx, cy, r, 0, Math.PI * 2); ctx.stroke(); ctx.fillStyle = '#336633'; ctx.font = '10px sans-serif'; ctx.fillText(dist + 'm', cx + r + 5, cy); }); // Draw crosshairs ctx.strokeStyle = '#1a3a1a'; ctx.beginPath(); ctx.moveTo(cx, 20); ctx.lineTo(cx, h - 20); ctx.moveTo(20, cy); ctx.lineTo(w - 20, cy); ctx.stroke(); // Draw center point (scanner) - magenta star-like ctx.fillStyle = '#ff0080'; ctx.beginPath(); // Draw a star shape const starRadius = 10; for (let i = 0; i < 5; i++) { const angle = (i * 144 - 90) * Math.PI / 180; const x = cx + starRadius * Math.cos(angle); const y = cy + starRadius * Math.sin(angle); if (i === 0) ctx.moveTo(x, y); else ctx.lineTo(x, y); } ctx.closePath(); ctx.fill(); ctx.strokeStyle = 'white'; ctx.lineWidth = 1.5; ctx.stroke(); if (!scanData) return; const wifi = filters.wifi ? (scanData.wifi_networks || []) : []; const bt = filters.bluetooth ? (scanData.bluetooth_devices || []) : []; // Draw WiFi devices wifi.forEach((net) => { const effectiveDist = getEffectiveDistance(net); const dist = Math.min(effectiveDist, maxDist); const r = (dist / maxDist) * maxRadius; const angle = hashString(net.bssid) % 360 * Math.PI / 180; const x = cx + r * Math.cos(angle); const y = cy + r * Math.sin(angle); const size = Math.max(4, 12 + (net.rssi + 90) / 5); // Store position for hit detection devicePositions.push({ type: 'wifi', data: net, x: x, y: y, radius: Math.max(size / 2, 8) // Minimum clickable area }); // Draw glow const gradient = ctx.createRadialGradient(x, y, 0, x, y, size * 2); gradient.addColorStop(0, 'rgba(0, 255, 136, 0.3)'); gradient.addColorStop(1, 'rgba(0, 255, 136, 0)'); ctx.fillStyle = gradient; ctx.beginPath(); ctx.arc(x, y, size * 2, 0, Math.PI * 2); ctx.fill(); // Draw dot (with selection highlight) const isSelected = selectedDevice && selectedDevice.type === 'wifi' && selectedDevice.data.bssid === net.bssid; if (isSelected) { ctx.strokeStyle = '#ffffff'; ctx.lineWidth = 2; ctx.beginPath(); ctx.arc(x, y, size / 2 + 4, 0, Math.PI * 2); ctx.stroke(); } ctx.fillStyle = APP_CONFIG.colors.wifi; ctx.beginPath(); ctx.arc(x, y, size / 2, 0, Math.PI * 2); ctx.fill(); // Label for strong signals if (net.rssi > -70) { ctx.fillStyle = APP_CONFIG.colors.wifi; ctx.font = '10px sans-serif'; ctx.textAlign = 'center'; ctx.fillText(net.ssid.substring(0, 12), x, y - size); } }); // Draw Bluetooth devices bt.forEach((dev) => { const effectiveDist = getEffectiveDistance(dev); const dist = Math.min(effectiveDist, maxDist); const r = (dist / maxDist) * maxRadius; const angle = hashString(dev.address) % 360 * Math.PI / 180; const x = cx + r * Math.cos(angle); const y = cy + r * Math.sin(angle); const size = Math.max(3, 10 + (dev.rssi + 90) / 5); const isMoving = dev.is_moving === true; const missCount = dev.miss_count || 0; // Calculate opacity: 1.0 -> 0.6 -> 0.3 based on miss count const opacity = missCount === 0 ? 1.0 : (missCount === 1 ? 0.6 : 0.3); const color = isMoving ? '#9b59b6' : APP_CONFIG.colors.bluetooth; // Store position for hit detection devicePositions.push({ type: 'bluetooth', data: dev, x: x, y: y, radius: Math.max(size / 2, 8) // Minimum clickable area }); // Save context for opacity ctx.save(); ctx.globalAlpha = opacity; // Draw glow const gradient = ctx.createRadialGradient(x, y, 0, x, y, size * 2); if (isMoving) { gradient.addColorStop(0, 'rgba(155, 89, 182, 0.5)'); gradient.addColorStop(1, 'rgba(155, 89, 182, 0)'); } else { gradient.addColorStop(0, 'rgba(77, 171, 247, 0.3)'); gradient.addColorStop(1, 'rgba(77, 171, 247, 0)'); } ctx.fillStyle = gradient; ctx.beginPath(); ctx.arc(x, y, size * 2, 0, Math.PI * 2); ctx.fill(); // Draw dot (with selection highlight) const isSelected = selectedDevice && selectedDevice.type === 'bluetooth' && selectedDevice.data.address === dev.address; if (isSelected) { ctx.strokeStyle = '#ffffff'; ctx.lineWidth = 2; ctx.beginPath(); ctx.arc(x, y, size / 2 + 4, 0, Math.PI * 2); ctx.stroke(); } ctx.fillStyle = color; ctx.beginPath(); ctx.arc(x, y, size / 2, 0, Math.PI * 2); ctx.fill(); // Restore context (for opacity) ctx.restore(); }); // Legend ctx.font = '11px sans-serif'; ctx.textAlign = 'left'; ctx.fillStyle = filters.wifi ? '#666' : '#333'; ctx.fillText('● WiFi', 20, h - 30); ctx.fillStyle = filters.wifi ? APP_CONFIG.colors.wifi : '#333'; ctx.fillText('●', 20, h - 30); if (!filters.wifi) { ctx.fillStyle = '#333'; ctx.fillText(' (hidden)', 55, h - 30); } ctx.fillStyle = filters.bluetooth ? '#666' : '#333'; ctx.fillText('● Bluetooth', 120, h - 30); ctx.fillStyle = filters.bluetooth ? APP_CONFIG.colors.bluetooth : '#333'; ctx.fillText('●', 120, h - 30); if (!filters.bluetooth) { ctx.fillStyle = '#333'; ctx.fillText(' (hidden)', 185, h - 30); } } // Update map markers function updateMapMarkers() { markers.forEach(m => map.removeLayer(m)); markers = []; if (!scanData) return; const lat = parseFloat(document.getElementById('lat-input').value); const lon = parseFloat(document.getElementById('lon-input').value); // Add center marker (this scanner) - distinct star shape const scannerIcon = `
`; const centerMarker = L.marker([lat, lon], { icon: L.divIcon({ className: 'center-marker scanner-marker', html: scannerIcon, iconSize: [24, 24], iconAnchor: [12, 12] }) }).addTo(map).bindPopup('📍 This Scanner'); markers.push(centerMarker); // Add peer scanner markers (async, won't block) fetch('/api/peers') .then(r => r.json()) .then(data => { (data.peers || []).forEach(peer => { if (peer.latitude && peer.longitude) { const peerIcon = `
`; const peerMarker = L.marker([peer.latitude, peer.longitude], { icon: L.divIcon({ className: 'peer-marker scanner-marker', html: peerIcon, iconSize: [20, 20], iconAnchor: [10, 10] }) }).addTo(map).bindPopup(`📡 ${peer.name || peer.scanner_id}
Floor: ${peer.floor ?? '?'}`); markers.push(peerMarker); } }); }) .catch(() => {}); // Ignore errors const wifi = filters.wifi ? (scanData.wifi_networks || []) : []; const bt = filters.bluetooth ? (scanData.bluetooth_devices || []) : []; // Add WiFi markers wifi.forEach(net => { const dist = getEffectiveDistance(net); const angle = hashString(net.bssid) % 360; const latOffset = (dist * Math.cos(angle * Math.PI / 180)) / 111000; const lonOffset = (dist * Math.sin(angle * Math.PI / 180)) / (111000 * Math.cos(lat * Math.PI / 180)); const hasCustomDist = net.custom_distance_m !== null && net.custom_distance_m !== undefined; const distLabel = hasCustomDist ? `${dist}m (custom)` : `~${net.estimated_distance_m}m`; const marker = L.circleMarker([lat + latOffset, lon + lonOffset], { radius: Math.max(5, 10 + (net.rssi + 90) / 10), color: APP_CONFIG.colors.wifi, fillColor: APP_CONFIG.colors.wifi, fillOpacity: 0.6, weight: 2 }).addTo(map).bindPopup(` 📶 ${escapeHtml(net.ssid)}
Signal: ${net.rssi} dBm
Distance: ${distLabel}
Channel: ${net.channel}
${escapeHtml(net.manufacturer)} `); markers.push(marker); }); // Add Bluetooth markers bt.forEach(dev => { const dist = getEffectiveDistance(dev); const angle = hashString(dev.address) % 360; const latOffset = (dist * Math.cos(angle * Math.PI / 180)) / 111000; const lonOffset = (dist * Math.sin(angle * Math.PI / 180)) / (111000 * Math.cos(lat * Math.PI / 180)); const hasCustomDist = dev.custom_distance_m !== null && dev.custom_distance_m !== undefined; const distLabel = hasCustomDist ? `${dist}m (custom)` : `~${dev.estimated_distance_m}m`; // Calculate opacity based on miss count const missCount = dev.miss_count || 0; const opacity = missCount === 0 ? 0.6 : (missCount === 1 ? 0.4 : 0.2); const marker = L.circleMarker([lat + latOffset, lon + lonOffset], { radius: Math.max(4, 8 + (dev.rssi + 90) / 10), color: APP_CONFIG.colors.bluetooth, fillColor: APP_CONFIG.colors.bluetooth, fillOpacity: opacity, opacity: opacity + 0.2, weight: 2 }).addTo(map).bindPopup(` 🔵 ${escapeHtml(dev.name)}
Signal: ${dev.rssi} dBm
Distance: ${distLabel}
Type: ${escapeHtml(dev.device_type)}
${escapeHtml(dev.manufacturer)} `); markers.push(marker); }); // Fit bounds if we have markers if (markers.length > 1) { const group = L.featureGroup(markers); map.fitBounds(group.getBounds().pad(0.1)); } } // Identify Bluetooth device async function identifyDevice(address, btn) { btn.classList.add('loading'); btn.disabled = true; btn.textContent = '⏳'; const servicesDiv = document.getElementById('services-' + address.replace(/:/g, '')); try { const response = await fetch(`/api/bluetooth/identify/${encodeURIComponent(address)}`); const data = await response.json(); if (response.ok && !data.error) { const card = btn.closest('.device-card'); if (data.name && data.name !== '') { const nameEl = card.querySelector('.device-name'); nameEl.textContent = data.name; } if (data.device_type && data.device_type !== 'Unknown') { const mfrEl = card.querySelector('.manufacturer'); mfrEl.innerHTML = `${escapeHtml(data.device_type)}`; } if (data.services && data.services.length > 0) { servicesDiv.innerHTML = data.services .slice(0, 6) .map(s => `${escapeHtml(s)}`) .join(''); servicesDiv.classList.add('visible'); } btn.textContent = '✓'; btn.title = data.icon ? `Identified as: ${data.icon}` : 'Device identified'; } else { btn.textContent = '❌'; btn.title = data.error || 'Could not identify device'; } } catch (error) { console.error('Identify error:', error); btn.textContent = '❌'; btn.title = 'Error identifying device'; } btn.classList.remove('loading'); btn.disabled = false; } // Utility: hash string to number function hashString(str) { let hash = 0; for (let i = 0; i < str.length; i++) { hash = ((hash << 5) - hash) + str.charCodeAt(i); hash = hash & hash; } return Math.abs(hash); } // Utility: escape HTML function escapeHtml(text) { const div = document.createElement('div'); div.textContent = text || ''; return div.innerHTML; } // ========== Radar Click & Hover Handling ========== // Handle radar canvas click function handleRadarClick(e) { const canvas = document.getElementById('radar-canvas'); const rect = canvas.getBoundingClientRect(); const x = e.clientX - rect.left; const y = e.clientY - rect.top; // Check if click hits any device const hitDevice = findDeviceAtPosition(x, y); if (hitDevice) { selectedDevice = hitDevice; showDetailPanel(hitDevice, e.clientX, e.clientY); drawRadar(); // Redraw to show selection highlight } else { closeDetailPanel(); } } // Handle radar canvas hover function handleRadarHover(e) { const canvas = document.getElementById('radar-canvas'); const rect = canvas.getBoundingClientRect(); const x = e.clientX - rect.left; const y = e.clientY - rect.top; const hitDevice = findDeviceAtPosition(x, y); const tooltip = document.getElementById('radar-tooltip'); if (hitDevice) { canvas.style.cursor = 'pointer'; const name = hitDevice.type === 'wifi' ? hitDevice.data.ssid : hitDevice.data.name; tooltip.textContent = name || 'Unknown'; tooltip.style.left = (e.clientX - rect.left + 10) + 'px'; tooltip.style.top = (e.clientY - rect.top - 25) + 'px'; tooltip.classList.add('visible'); } else { canvas.style.cursor = 'default'; tooltip.classList.remove('visible'); } } // Hide radar tooltip function hideRadarTooltip() { const tooltip = document.getElementById('radar-tooltip'); if (tooltip) { tooltip.classList.remove('visible'); } } // Find device at given position using distance formula function findDeviceAtPosition(x, y) { for (const device of devicePositions) { const dx = x - device.x; const dy = y - device.y; const distance = Math.sqrt(dx * dx + dy * dy); if (distance <= device.radius) { return device; } } return null; } // ========== Detail Panel ========== // Show device detail panel function showDetailPanel(device, clientX, clientY) { const panel = document.getElementById('device-detail-panel'); const container = document.querySelector('.map-container'); const containerRect = container.getBoundingClientRect(); // Position panel near click, but keep within bounds let left = clientX - containerRect.left + 15; let top = clientY - containerRect.top - 50; // Keep panel within container const panelWidth = 250; const panelHeight = 200; if (left + panelWidth > containerRect.width) { left = clientX - containerRect.left - panelWidth - 15; } if (top + panelHeight > containerRect.height) { top = containerRect.height - panelHeight - 10; } if (top < 10) { top = 10; } panel.style.left = left + 'px'; panel.style.top = top + 'px'; // Populate panel const isWifi = device.type === 'wifi'; const data = device.data; panel.className = 'device-detail-panel ' + device.type; document.getElementById('detail-icon').textContent = isWifi ? '📶' : '🔵'; document.getElementById('detail-name').textContent = isWifi ? data.ssid : data.name; document.getElementById('detail-signal').textContent = data.rssi + ' dBm'; document.getElementById('detail-distance').textContent = '~' + data.estimated_distance_m + 'm'; document.getElementById('detail-manufacturer').textContent = data.manufacturer || 'Unknown'; // Show/hide type-specific fields const channelRow = document.getElementById('detail-channel-row'); const typeRow = document.getElementById('detail-type-row'); const addressRow = document.getElementById('detail-address-row'); if (isWifi) { channelRow.style.display = 'flex'; document.getElementById('detail-channel').textContent = data.channel || 'N/A'; typeRow.style.display = 'none'; addressRow.style.display = 'flex'; document.getElementById('detail-address').textContent = data.bssid || 'N/A'; } else { channelRow.style.display = 'none'; typeRow.style.display = 'flex'; document.getElementById('detail-type').textContent = data.device_type || 'Unknown'; addressRow.style.display = 'flex'; document.getElementById('detail-address').textContent = data.address || 'N/A'; } // Show panel with animation panel.classList.remove('hidden'); requestAnimationFrame(() => { panel.classList.add('visible'); }); } // Close device detail panel function closeDetailPanel() { const panel = document.getElementById('device-detail-panel'); panel.classList.remove('visible'); setTimeout(() => { panel.classList.add('hidden'); }, 200); selectedDevice = null; // Redraw to remove selection highlight if (currentView === 'radar') { drawRadar(); } } // ========== Auto-Scan Functions ========== // Load auto-scan status async function loadAutoScanStatus() { try { const response = await fetch('/api/autoscan'); if (response.ok) { const data = await response.json(); updateAutoScanUI(data); } } catch (error) { console.error('Error loading auto-scan status:', error); } } // Update auto-scan UI elements function updateAutoScanUI(status) { autoScanEnabled = status.enabled || status.running; // Update header button const btn = document.getElementById('autoscan-btn'); if (btn) { btn.textContent = autoScanEnabled ? '⏱️ Auto: On' : '⏱️ Auto: Off'; btn.classList.toggle('active', autoScanEnabled); } // Update sidebar status const statusEl = document.getElementById('autoscan-status'); if (statusEl) { statusEl.textContent = autoScanEnabled ? 'Running' : 'Off'; statusEl.classList.toggle('running', autoScanEnabled); } // Update settings inputs const intervalInput = document.getElementById('autoscan-interval'); if (intervalInput && status.interval_minutes) { intervalInput.value = status.interval_minutes; } const labelInput = document.getElementById('autoscan-label'); if (labelInput && status.location_label) { labelInput.value = status.location_label; } // Update info const infoEl = document.getElementById('autoscan-info'); if (infoEl) { if (status.last_scan_time) { const time = new Date(status.last_scan_time).toLocaleTimeString(); const result = status.last_scan_result; infoEl.textContent = `Last: ${time} (WiFi: ${result?.wifi_count || 0}, BT: ${result?.bt_count || 0})`; } else if (autoScanEnabled) { infoEl.textContent = 'Waiting for first scan...'; } else { infoEl.textContent = 'Last scan: --'; } } // Start/stop polling for updates when enabled if (autoScanEnabled && !autoScanPollInterval) { autoScanPollInterval = setInterval(pollAutoScanStatus, 30000); } else if (!autoScanEnabled && autoScanPollInterval) { clearInterval(autoScanPollInterval); autoScanPollInterval = null; } } // Poll for auto-scan status updates async function pollAutoScanStatus() { try { const response = await fetch('/api/autoscan'); if (response.ok) { const data = await response.json(); updateAutoScanUI(data); // Reload scan data if there's a new scan if (data.last_scan_result) { loadLatestScan(); } } } catch (error) { console.error('Error polling auto-scan status:', error); } } // Toggle auto-scan from header button function toggleAutoScan() { if (autoScanEnabled) { stopAutoScan(); } else { startAutoScan(); } } // Start auto-scanning async function startAutoScan() { const interval = parseInt(document.getElementById('autoscan-interval')?.value) || 5; const label = document.getElementById('autoscan-label')?.value || 'auto_scan'; try { const response = await fetch('/api/autoscan/start', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ interval_minutes: interval, location_label: label, scan_wifi: filters.wifi, scan_bluetooth: filters.bluetooth, save: false }) }); if (response.ok) { const data = await response.json(); updateAutoScanUI(data); const services = []; if (filters.wifi) services.push('WiFi'); if (filters.bluetooth) services.push('BT'); document.getElementById('scan-status').textContent = `Auto-scan: ${services.join('+')} every ${interval} min`; } } catch (error) { console.error('Error starting auto-scan:', error); } } // Stop auto-scanning async function stopAutoScan() { try { const response = await fetch('/api/autoscan/stop', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ save: false }) }); if (response.ok) { const data = await response.json(); updateAutoScanUI(data); document.getElementById('scan-status').textContent = 'Auto-scan stopped'; } } catch (error) { console.error('Error stopping auto-scan:', error); } } // ========== Device Position Functions ========== // Load saved device positions from database async function loadDevicePositions() { try { const response = await fetch('/api/device/floors'); if (response.ok) { const data = await response.json(); // Handle both old format (just floors) and new format (floors + positions) if (data.positions) { manualPositions = data.positions; console.log('[Positions] Loaded', Object.keys(manualPositions).length, 'manual positions'); } } } catch (error) { console.error('Error loading device positions:', error); } } // Get device position (manual or RSSI-based) function getDevicePosition(device, scannerLat, scannerLon, minDistanceM) { const deviceId = device.bssid || device.address; const customPos = manualPositions[deviceId]; // If device has manual position, use it if (customPos && customPos.lat_offset != null && customPos.lon_offset != null) { return { lat: scannerLat + customPos.lat_offset, lon: scannerLon + customPos.lon_offset, isManual: true }; } // Otherwise calculate from RSSI/distance const effectiveDist = getEffectiveDistance(device); const dist = Math.max(effectiveDist, minDistanceM); const angle = hashString(deviceId) % 360; const latOffset = (dist * Math.cos(angle * Math.PI / 180)) / 111000; const lonOffset = (dist * Math.sin(angle * Math.PI / 180)) / (111000 * Math.cos(scannerLat * Math.PI / 180)); return { lat: scannerLat + latOffset, lon: scannerLon + lonOffset, isManual: false }; } // Update device position via API (called after drag) async function updateDevicePosition(deviceId, latOffset, lonOffset) { try { const response = await fetch(`/api/device/${encodeURIComponent(deviceId)}/position`, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ lat_offset: latOffset, lon_offset: lonOffset }) }); if (response.ok) { const data = await response.json(); console.log('[Position] Updated', deviceId, 'to offset', latOffset.toFixed(6), lonOffset.toFixed(6)); // Update local cache manualPositions[deviceId] = { lat_offset: latOffset, lon_offset: lonOffset }; return true; } else { const error = await response.json(); console.error('[Position] Failed to update:', error.error); return false; } } catch (error) { console.error('[Position] Error updating position:', error); return false; } } // Reset device position to auto (RSSI-based) async function resetDevicePosition(deviceId) { try { const response = await fetch(`/api/device/${encodeURIComponent(deviceId)}/position`, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ lat_offset: null, lon_offset: null }) }); if (response.ok) { console.log('[Position] Reset', deviceId, 'to auto'); // Remove from local cache delete manualPositions[deviceId]; // Refresh markers to show new position update3DMarkers(); return true; } else { console.error('[Position] Failed to reset position'); return false; } } catch (error) { console.error('[Position] Error resetting position:', error); return false; } } // Handle scanner (primary source) drag end async function onScannerDragEnd(marker) { const lngLat = marker.getLngLat(); const newLat = lngLat.lat; const newLon = lngLat.lng; console.log('[Scanner] Repositioned to', newLat.toFixed(6), newLon.toFixed(6)); // Update the input fields document.getElementById('lat-input').value = newLat.toFixed(6); document.getElementById('lon-input').value = newLon.toFixed(6); // Persist to config file (survives restarts) try { const response = await fetch('/api/config', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ gps: { latitude: newLat, longitude: newLon }, save: true }) }); if (response.ok) { console.log('[Scanner] Position saved to config'); } } catch (error) { console.error('[Scanner] Error saving position:', error); } // Refresh all markers (device positions are relative to scanner) update3DMarkers(); } // Handle marker drag end async function onMarkerDragEnd(marker, deviceId, scannerLat, scannerLon) { const lngLat = marker.getLngLat(); const latOffset = lngLat.lat - scannerLat; const lonOffset = lngLat.lng - scannerLon; const success = await updateDevicePosition(deviceId, latOffset, lonOffset); if (success) { // Update marker element to show manual position indicator const el = marker.getElement(); if (el) { el.classList.add('has-manual-position'); } // Refresh markers to update popup content update3DMarkers(); } else { // Revert marker to original position on failure update3DMarkers(); } } // ========== 3D Map Functions ========== // Initialize 3D map with MapLibre GL function init3DMap() { const container = document.getElementById('map-3d'); if (!container || typeof maplibregl === 'undefined') { console.warn('MapLibre GL not available or container not found'); return; } const lat = APP_CONFIG.defaultLat; const lon = APP_CONFIG.defaultLon; try { map3d = new maplibregl.Map({ container: 'map-3d', style: APP_CONFIG.maplibre?.style || 'https://basemaps.cartocdn.com/gl/positron-gl-style/style.json', center: [lon, lat], zoom: 18, pitch: 45, bearing: -17.6, antialias: true }); // Add navigation controls map3d.addControl(new maplibregl.NavigationControl({ visualizePitch: true }), 'top-left'); // Add 3D buildings when style loads map3d.on('load', () => { console.log('3D map loaded at', map3d.getCenter()); map3dLoaded = true; add3DBuildingLayer(); setup3DDeviceLayers(); update3DMarkers(); }); map3d.on('error', (e) => { console.error('3D map error:', e); }); // DOM markers handle their own click events via popups } catch (e) { console.error('Failed to initialize 3D map:', e); } } // Add 3D building extrusion layer function add3DBuildingLayer() { if (!map3d) return; const style = map3d.getStyle(); if (style && style.sources) { const existingSources = Object.keys(style.sources); for (const sourceName of existingSources) { const source = style.sources[sourceName]; if (source.type === 'vector') { try { if (map3d.getLayer('3d-buildings')) { return; } map3d.addLayer({ 'id': '3d-buildings', 'source': sourceName, 'source-layer': 'building', 'type': 'fill-extrusion', 'minzoom': 14, 'paint': { 'fill-extrusion-color': '#1a3a5c', 'fill-extrusion-height': [ 'interpolate', ['linear'], ['zoom'], 14, 0, 15, ['coalesce', ['get', 'height'], ['get', 'render_height'], 10] ], 'fill-extrusion-base': [ 'interpolate', ['linear'], ['zoom'], 14, 0, 15, ['coalesce', ['get', 'min_height'], 0] ], 'fill-extrusion-opacity': 0.3 // More transparent to see markers } }); console.log('Added 3D buildings from source:', sourceName); break; } catch (e) { console.log('Could not add 3D buildings from source:', sourceName); } } } } } // Setup for 3D device visualization - using DOM markers instead of fill-extrusion function setup3DDeviceLayers() { // No GeoJSON layers needed - we use DOM markers console.log('3D device layers ready (using DOM markers)'); } // Create a small polygon (square) around a point for fill-extrusion function createDevicePolygon(lon, lat, sizeMeters) { // Convert meters to degrees (approximate) const latOffset = sizeMeters / 111000; const lonOffset = sizeMeters / (111000 * Math.cos(lat * Math.PI / 180)); return [ [ [lon - lonOffset, lat - latOffset], [lon + lonOffset, lat - latOffset], [lon + lonOffset, lat + latOffset], [lon - lonOffset, lat + latOffset], [lon - lonOffset, lat - latOffset] ] ]; } // Calculate height in meters for a given floor function getFloorHeightMeters(floor) { const buildingConfig = APP_CONFIG.building || {}; const floorHeightM = buildingConfig.floorHeightM || 3.0; const groundFloor = buildingConfig.groundFloorNumber || 0; // Height = (floor - groundFloor) * floorHeight return (floor - groundFloor) * floorHeightM; } // Update 3D map markers using DOM markers function update3DMarkers() { if (!map3d || !map3dLoaded) { console.log('update3DMarkers: map3d not ready'); return; } // Save currently open popup before removing markers let savedPopupDeviceId = openPopupDeviceId; map3dMarkers.forEach(m => { if (m.getPopup() && m.getPopup().isOpen()) { savedPopupDeviceId = m._deviceId || savedPopupDeviceId; } }); // Remove existing markers map3dMarkers.forEach(m => m.remove()); map3dMarkers = []; const lat = parseFloat(document.getElementById('lat-input').value) || APP_CONFIG.defaultLat; const lon = parseFloat(document.getElementById('lon-input').value) || APP_CONFIG.defaultLon; const buildingConfig = APP_CONFIG.building || {}; const scannerFloor = buildingConfig.currentFloor || 0; // Minimum distance from center to place markers (meters) - outside building const minDistanceM = 15; // Calculate pixel offset based on floor (to simulate elevation) // Higher floors = larger negative offset (moves marker up on screen) const pixelsPerFloor = 18; const groundFloor = buildingConfig.groundFloorNumber || 0; // Add scanner position marker at center (draggable for fine-grained positioning) const scannerEl = document.createElement('div'); scannerEl.className = 'marker-3d center draggable'; scannerEl.innerHTML = `
📍
F${scannerFloor}
`; scannerEl.title = `Your Position - Floor ${scannerFloor} (drag to reposition)`; const scannerOffset = (scannerFloor - groundFloor) * pixelsPerFloor; const scannerMarker = new maplibregl.Marker({ element: scannerEl, offset: [0, -scannerOffset], draggable: true }) .setLngLat([lon, lat]) .setPopup(new maplibregl.Popup().setHTML(` 📍 Your Position
Floor: ${scannerFloor}
Lat: ${lat.toFixed(6)}
Lon: ${lon.toFixed(6)}
Drag marker to reposition
`)) .addTo(map3d); // Handle scanner marker drag scannerMarker.on('dragend', () => onScannerDragEnd(scannerMarker)); scannerMarker._deviceId = '__scanner__'; map3dMarkers.push(scannerMarker); if (!scanData) return; const wifi = filters.wifi ? (scanData.wifi_networks || []) : []; const bt = filters.bluetooth ? (scanData.bluetooth_devices || []) : []; // Filter by floor if not 'all' const filteredWifi = currentFloor === 'all' ? wifi : wifi.filter(n => n.floor === parseInt(currentFloor) || n.floor === null); const filteredBt = currentFloor === 'all' ? bt : bt.filter(d => d.floor === parseInt(currentFloor) || d.floor === null); console.log('Floor filter:', currentFloor, '| WiFi:', wifi.length, '->', filteredWifi.length, '| BT:', bt.length, '->', filteredBt.length); // Add WiFi markers filteredWifi.forEach((net) => { const wifiDeviceId = net.bssid; const deviceFloor = net.floor !== null && net.floor !== undefined ? net.floor : null; const hasCustomDist = net.custom_distance_m !== null && net.custom_distance_m !== undefined; const effectiveDist = getEffectiveDistance(net); // Get position (manual or RSSI-based) const pos = getDevicePosition(net, lat, lon, minDistanceM); const hasManualPosition = pos.isManual; // Determine if marker should be draggable (only floor-assigned devices) const isDraggable = deviceFloor !== null; const floorLabel = deviceFloor !== null ? `F${deviceFloor}` : 'F?'; const el = document.createElement('div'); el.className = 'marker-3d wifi'; if (isDraggable) el.classList.add('draggable'); if (hasManualPosition) el.classList.add('has-manual-position'); el.innerHTML = `
📶
${floorLabel}
`; el.title = `${net.ssid} - ${deviceFloor !== null ? 'Floor ' + deviceFloor : 'Unknown floor'}${hasManualPosition ? ' (Manual position)' : ''}`; const distLabel = hasCustomDist ? `${effectiveDist}m (custom)` : `~${net.estimated_distance_m}m`; const positionStatus = hasManualPosition ? 'Manual' : 'Auto'; const positionClass = hasManualPosition ? 'manual' : 'auto'; const resetBtn = hasManualPosition ? `` : ''; const dragHint = isDraggable && !hasManualPosition ? '
Drag marker to set position
' : ''; const popup = new maplibregl.Popup({ offset: 25 }).setHTML(` 📶 ${escapeHtml(net.ssid)}
Signal: ${net.rssi} dBm
Distance: ${distLabel}
Channel: ${net.channel}
${escapeHtml(net.manufacturer)}
${resetBtn} ${dragHint} `); // Offset marker up based on floor (unknown floors at ground level) const wifiOffset = deviceFloor !== null ? (deviceFloor - groundFloor) * pixelsPerFloor : 0; const marker = new maplibregl.Marker({ element: el, offset: [0, -wifiOffset], draggable: isDraggable }) .setLngLat([pos.lon, pos.lat]) .setPopup(popup) .addTo(map3d); // Handle drag end for floor-assigned devices if (isDraggable) { marker.on('dragend', () => onMarkerDragEnd(marker, wifiDeviceId, lat, lon)); } marker._deviceId = wifiDeviceId; popup.on('open', () => { openPopupDeviceId = wifiDeviceId; }); popup.on('close', () => { if (openPopupDeviceId === wifiDeviceId) openPopupDeviceId = null; }); map3dMarkers.push(marker); }); // Add Bluetooth markers filteredBt.forEach((dev) => { const btDeviceId = dev.address; const deviceFloor = dev.floor !== null && dev.floor !== undefined ? dev.floor : null; const hasCustomDist = dev.custom_distance_m !== null && dev.custom_distance_m !== undefined; const effectiveDist = getEffectiveDistance(dev); // Get position (manual or RSSI-based) const pos = getDevicePosition(dev, lat, lon, minDistanceM); const hasManualPosition = pos.isManual; // Determine if marker should be draggable (only floor-assigned devices) const isDraggable = deviceFloor !== null; const btFloorLabel = deviceFloor !== null ? `F${deviceFloor}` : 'F?'; const isMoving = dev.is_moving === true; const missCount = dev.miss_count || 0; // Calculate opacity: 1.0 -> 0.6 -> 0.3 based on miss count const opacity = missCount === 0 ? 1.0 : (missCount === 1 ? 0.6 : 0.3); const el = document.createElement('div'); el.className = `marker-3d bluetooth${isMoving ? ' moving' : ''}`; if (isDraggable) el.classList.add('draggable'); if (hasManualPosition) el.classList.add('has-manual-position'); el.style.opacity = opacity; el.style.transition = 'opacity 0.5s ease'; el.innerHTML = `
${isMoving ? '🟣' : '🔵'}
${btFloorLabel}
`; el.title = `${dev.name} - ${deviceFloor !== null ? 'Floor ' + deviceFloor : 'Unknown floor'}${isMoving ? ' (MOVING)' : ''}${hasManualPosition ? ' (Manual position)' : ''}${missCount > 0 ? ` (fading: ${missCount}/${MAX_MISSED_SCANS})` : ''}`; const btDistLabel = hasCustomDist ? `${effectiveDist}m (custom)` : `~${dev.estimated_distance_m}m`; const positionStatus = hasManualPosition ? 'Manual' : 'Auto'; const positionClass = hasManualPosition ? 'manual' : 'auto'; const resetBtn = hasManualPosition ? `` : ''; const dragHint = isDraggable && !hasManualPosition ? '
Drag marker to set position
' : ''; const trailBtnHtml = isMoving ? ` ` : ''; const popup = new maplibregl.Popup({ offset: 25 }).setHTML(` ${isMoving ? '🟣' : '🔵'} ${escapeHtml(dev.name)}${isMoving ? ' (Moving)' : ''}
Signal: ${dev.rssi} dBm
Distance: ${btDistLabel}
Type: ${escapeHtml(dev.device_type)}
${escapeHtml(dev.manufacturer)}
${resetBtn} ${dragHint} ${trailBtnHtml} `); // Offset marker up based on floor (unknown floors at ground level) const btOffset = deviceFloor !== null ? (deviceFloor - groundFloor) * pixelsPerFloor : 0; const marker = new maplibregl.Marker({ element: el, offset: [0, -btOffset], draggable: isDraggable }) .setLngLat([pos.lon, pos.lat]) .setPopup(popup) .addTo(map3d); // Handle drag end for floor-assigned devices if (isDraggable) { marker.on('dragend', () => onMarkerDragEnd(marker, btDeviceId, lat, lon)); } marker._deviceId = btDeviceId; popup.on('open', () => { openPopupDeviceId = btDeviceId; }); popup.on('close', () => { if (openPopupDeviceId === btDeviceId) openPopupDeviceId = null; }); map3dMarkers.push(marker); }); // Reopen saved popup if device still exists if (savedPopupDeviceId) { const markerToOpen = map3dMarkers.find(m => m._deviceId === savedPopupDeviceId); if (markerToOpen && markerToOpen.getPopup()) { markerToOpen.togglePopup(); } } console.log('update3DMarkers:', map3dMarkers.length, 'DOM markers at floor', scannerFloor); // Update floor device count updateFloorDeviceCount(); } // ========== Floor Selector Functions ========== // Initialize floor selector function initFloorSelector() { // Set initial floor from building config if (APP_CONFIG.building && APP_CONFIG.building.enabled) { currentFloor = APP_CONFIG.building.currentFloor.toString(); } updateFloorSelector(); } // Update floor selector options based on building config and device data function updateFloorSelector() { const select = document.getElementById('floor-select'); if (!select) return; // Get current selection const currentSelection = select.value; // Clear options except "All Floors" select.innerHTML = ''; // Get floors from config or scan data const buildingConfig = APP_CONFIG.building || {}; const floors = buildingConfig.floors || 1; const groundFloor = buildingConfig.groundFloorNumber || 0; // Collect unique floors from scan data const deviceFloors = new Set(); if (scanData) { (scanData.wifi_networks || []).forEach(n => { if (n.floor !== null && n.floor !== undefined) { deviceFloors.add(n.floor); } }); (scanData.bluetooth_devices || []).forEach(d => { if (d.floor !== null && d.floor !== undefined) { deviceFloors.add(d.floor); } }); } // Add floor options // Use building config floors or detected floors, whichever is more const maxFloor = Math.max(floors - 1 + groundFloor, ...Array.from(deviceFloors)); const minFloor = Math.min(groundFloor, ...Array.from(deviceFloors)); for (let i = minFloor; i <= maxFloor; i++) { const option = document.createElement('option'); option.value = i; // Label floor appropriately if (i === 0) { option.textContent = 'Ground Floor (0)'; } else if (i < 0) { option.textContent = `Basement ${Math.abs(i)} (${i})`; } else { option.textContent = `Floor ${i}`; } // Mark floors that have devices if (deviceFloors.has(i)) { option.textContent += ' ●'; } select.appendChild(option); } // Restore selection if still valid, or use currentFloor if (currentSelection && (currentSelection === 'all' || select.querySelector(`option[value="${currentSelection}"]`))) { select.value = currentSelection; } else if (currentFloor && select.querySelector(`option[value="${currentFloor}"]`)) { select.value = currentFloor; } updateFloorDeviceCount(); } // Generate floor options HTML for dropdown function generateFloorOptions(selectedFloor) { const buildingConfig = APP_CONFIG.building || {}; const floors = buildingConfig.floors || 12; const groundFloor = buildingConfig.groundFloorNumber || 0; let options = ''; for (let i = groundFloor; i < groundFloor + floors; i++) { const selected = selectedFloor === i ? 'selected' : ''; const label = i === 0 ? 'Ground (0)' : `Floor ${i}`; options += ``; } return options; } // Update device floor via API async function updateDeviceFloor(deviceId, floorValue) { const floor = floorValue === '' ? null : parseInt(floorValue); try { const response = await fetch(`/api/device/${encodeURIComponent(deviceId)}/floor`, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ floor: floor }) }); if (response.ok) { console.log('Updated floor for', deviceId, 'to', floor); // Update local scan data if (scanData) { const wifiDevice = scanData.wifi_networks?.find(n => n.bssid === deviceId); if (wifiDevice) wifiDevice.floor = floor; const btDevice = scanData.bluetooth_devices?.find(d => d.address === deviceId); if (btDevice) btDevice.floor = floor; } // Refresh markers update3DMarkers(); updateFloorSelector(); } else { console.error('Failed to update floor:', await response.text()); } } catch (error) { console.error('Error updating floor:', error); } } // Update device custom distance via API async function updateDeviceDistance(deviceId, distanceValue) { const distance = distanceValue === '' ? null : parseFloat(distanceValue); try { const response = await fetch(`/api/device/${encodeURIComponent(deviceId)}/distance`, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ distance: distance }) }); if (response.ok) { console.log('Updated distance for', deviceId, 'to', distance); // Update local scan data if (scanData) { const wifiDevice = scanData.wifi_networks?.find(n => n.bssid === deviceId); if (wifiDevice) wifiDevice.custom_distance_m = distance; const btDevice = scanData.bluetooth_devices?.find(d => d.address === deviceId); if (btDevice) btDevice.custom_distance_m = distance; } // Refresh markers update3DMarkers(); drawRadar(); updateMapMarkers(); } else { console.error('Failed to update distance:', await response.text()); } } catch (error) { console.error('Error updating distance:', error); } } // Get effective distance (custom or estimated) function getEffectiveDistance(device) { if (device.custom_distance_m !== null && device.custom_distance_m !== undefined) { return device.custom_distance_m; } return device.estimated_distance_m; } // Set floor filter function setFloorFilter(floor) { console.log('setFloorFilter:', floor); currentFloor = floor; update3DMarkers(); updateFloorDeviceCount(); } // Update the floor device count display function updateFloorDeviceCount() { const countEl = document.getElementById('floor-device-count'); if (!countEl || !scanData) { if (countEl) countEl.textContent = '--'; return; } const wifi = scanData.wifi_networks || []; const bt = scanData.bluetooth_devices || []; let count; if (currentFloor === 'all') { count = wifi.length + bt.length; } else { const floorNum = parseInt(currentFloor); count = wifi.filter(n => n.floor === floorNum || n.floor === null).length + bt.filter(d => d.floor === floorNum || d.floor === null).length; } countEl.textContent = count; } // ========== Live BT Tracking Functions ========== // Toggle live BT tracking mode function toggleLiveTracking() { if (liveTrackingEnabled) { stopLiveTracking(); } else { startLiveTracking(); } } // Start live BT tracking function startLiveTracking() { if (liveTrackingInterval) { clearInterval(liveTrackingInterval); } liveTrackingEnabled = true; updateLiveTrackingUI(); console.log('Live BT tracking started'); // Do initial scan performLiveBTScan(); // Set up interval liveTrackingInterval = setInterval(performLiveBTScan, LIVE_TRACKING_INTERVAL_MS); } // Stop live BT tracking function stopLiveTracking() { liveTrackingEnabled = false; if (liveTrackingInterval) { clearInterval(liveTrackingInterval); liveTrackingInterval = null; } updateLiveTrackingUI(); console.log('Live BT tracking stopped'); } // Update live tracking UI function updateLiveTrackingUI() { const btn = document.getElementById('live-track-btn'); if (btn) { btn.textContent = liveTrackingEnabled ? '⏹ Stop Live' : '▶ Live Track'; btn.classList.toggle('active', liveTrackingEnabled); } const status = document.getElementById('scan-status'); if (status && liveTrackingEnabled) { status.textContent = 'Live tracking active...'; } } // Perform a quick BT-only scan for live tracking async function performLiveBTScan() { if (!liveTrackingEnabled) return; try { const response = await fetch('/api/scan/bt', { method: 'POST', headers: { 'Content-Type': 'application/json' } }); if (response.ok) { const data = await response.json(); const newBt = data.bluetooth_devices || []; // Track which devices were detected in this scan const detectedAddresses = new Set(newBt.map(d => d.address)); // Merge BT data with existing scan data, preserving custom distances and floors if (scanData) { const existingBt = scanData.bluetooth_devices || []; // Update existing devices with new RSSI, add new devices newBt.forEach(newDev => { const existing = existingBt.find(d => d.address === newDev.address); const newDist = newDev.estimated_distance_m; // Check for movement using statistical analysis const moving = isDeviceMoving(newDev.address, newDist); // Reset miss count - device was detected deviceMissCount[newDev.address] = 0; if (existing) { // Update RSSI and estimated distance, preserve custom values existing.rssi = newDev.rssi; existing.estimated_distance_m = newDev.estimated_distance_m; existing.signal_quality = newDev.signal_quality; existing.is_moving = moving; existing.miss_count = 0; // Preserve floor and custom_distance_m if set } else { // New device, add it newDev.is_moving = moving; existingBt.push(newDev); } }); // Increment miss count for devices not detected in this scan existingBt.forEach(dev => { if (!detectedAddresses.has(dev.address)) { deviceMissCount[dev.address] = (deviceMissCount[dev.address] || 0) + 1; dev.miss_count = deviceMissCount[dev.address]; } }); // Filter out devices that have been missed too many times const filteredBt = existingBt.filter(dev => { const missCount = deviceMissCount[dev.address] || 0; if (missCount >= MAX_MISSED_SCANS) { // Clean up tracking data for removed device delete deviceMissCount[dev.address]; delete deviceDistanceHistory[dev.address]; // Clear trail if showing if (deviceTrails[dev.address]) { clearDeviceTrail(dev.address); } console.log(`[Live] Removed ${dev.name} (missed ${missCount} scans)`); return false; } return true; }); scanData.bluetooth_devices = filteredBt; } else { // No existing scan data, use BT-only data data.bluetooth_devices.forEach(dev => { // Initialize history with first sample, not moving yet isDeviceMoving(dev.address, dev.estimated_distance_m); dev.is_moving = false; deviceMissCount[dev.address] = 0; }); scanData = { wifi_networks: [], bluetooth_devices: data.bluetooth_devices, timestamp: data.timestamp }; } // Update visualizations const status = document.getElementById('scan-status'); if (status) { const movingCount = scanData.bluetooth_devices.filter(d => d.is_moving).length; status.textContent = `Live: ${scanData.bluetooth_devices.length} BT (${movingCount} moving) @ ${new Date().toLocaleTimeString()}`; } // Update BT count document.getElementById('bt-count').textContent = scanData.bluetooth_devices.length; document.getElementById('bt-list-count').textContent = scanData.bluetooth_devices.length; // Refresh views drawRadar(); update3DMarkers(); updateMapMarkers(); } } catch (error) { console.error('Live BT scan error:', error); } } // ========== Device Trails Functions ========== // Toggle trail for a specific device (called from popup button) async function toggleDeviceTrail(deviceId, deviceName, deviceType) { const btnId = `trail-btn-${deviceId.replace(/:/g, '')}`; const btn = document.getElementById(btnId); // Check if trail already exists for this device if (deviceTrails[deviceId]) { // Hide trail clearDeviceTrail(deviceId); if (btn) { btn.textContent = 'Show Trail'; btn.classList.remove('active'); } console.log(`[Trails] Hidden trail for ${deviceName}`); } else { // Show trail - fetch and render if (btn) { btn.textContent = 'Loading...'; btn.classList.add('loading'); } await fetchDeviceTrail(deviceId, deviceName, deviceType); renderDeviceTrail(deviceId); if (btn) { btn.textContent = 'Hide Trail'; btn.classList.remove('loading'); btn.classList.add('active'); } console.log(`[Trails] Showing trail for ${deviceName}`); } } // Fetch trail data for a single device async function fetchDeviceTrail(deviceId, deviceName, deviceType) { try { // Get last 100 observations for trail const response = await fetch(`/api/history/devices/${encodeURIComponent(deviceId)}/rssi?limit=100`); if (!response.ok) { console.error(`[Trails] Failed to fetch trail for ${deviceId}`); return; } const data = await response.json(); const observations = data.observations || []; if (observations.length < 2) { console.log(`[Trails] Not enough data points for ${deviceId} (${observations.length})`); return; } // Convert observations to trail points with positions const lat = parseFloat(document.getElementById('lat-input').value) || APP_CONFIG.defaultLat; const lon = parseFloat(document.getElementById('lon-input').value) || APP_CONFIG.defaultLon; const angle = hashString(deviceId) % 360; const trailPoints = observations.map(obs => { const dist = obs.distance_m || 5; const latOffset = (dist * Math.cos(angle * Math.PI / 180)) / 111000; const lonOffset = (dist * Math.sin(angle * Math.PI / 180)) / (111000 * Math.cos(lat * Math.PI / 180)); return { timestamp: obs.timestamp, distance: dist, lat: lat + latOffset, lon: lon + lonOffset, rssi: obs.rssi, floor: obs.floor }; }); // Store trail data deviceTrails[deviceId] = { type: deviceType, name: deviceName, points: trailPoints.reverse() // Oldest first for line drawing }; console.log(`[Trails] Fetched ${trailPoints.length} points for ${deviceName}`); } catch (error) { console.error(`[Trails] Error fetching trail for ${deviceId}:`, error); } } // Render trail for a single device on the 3D map function renderDeviceTrail(deviceId) { if (!map3d || !map3dLoaded) return; const trail = deviceTrails[deviceId]; if (!trail || trail.points.length < 2) return; const safeId = deviceId.replace(/:/g, '-'); const sourceId = `trail-source-${safeId}`; const layerId = `trail-layer-${safeId}`; const pointsLayerId = `trail-points-${safeId}`; // Remove existing trail for this device if any clearDeviceTrailLayers(safeId); // Create GeoJSON line coordinates const coordinates = trail.points.map(p => [p.lon, p.lat]); // Purple color for moving devices const color = '#9b59b6'; // Add source for trail line map3d.addSource(sourceId, { type: 'geojson', data: { type: 'Feature', properties: { deviceId: deviceId, name: trail.name, type: trail.type }, geometry: { type: 'LineString', coordinates: coordinates } } }); // Add trail line layer map3d.addLayer({ id: layerId, type: 'line', source: sourceId, layout: { 'line-join': 'round', 'line-cap': 'round' }, paint: { 'line-color': color, 'line-width': 3, 'line-opacity': 0.8, 'line-dasharray': [2, 1] } }); // Add trail points (circles at each observation) const pointFeatures = trail.points.map((p, i) => ({ type: 'Feature', properties: { index: i, timestamp: p.timestamp, rssi: p.rssi, distance: p.distance }, geometry: { type: 'Point', coordinates: [p.lon, p.lat] } })); map3d.addSource(`${sourceId}-points`, { type: 'geojson', data: { type: 'FeatureCollection', features: pointFeatures } }); map3d.addLayer({ id: pointsLayerId, type: 'circle', source: `${sourceId}-points`, paint: { 'circle-radius': 4, 'circle-color': color, 'circle-opacity': 0.6, 'circle-stroke-width': 1, 'circle-stroke-color': '#ffffff', 'circle-stroke-opacity': 0.5 } }); console.log(`[Trails] Rendered trail for ${trail.name} with ${trail.points.length} points`); } // Clear trail for a specific device function clearDeviceTrail(deviceId) { const safeId = deviceId.replace(/:/g, '-'); clearDeviceTrailLayers(safeId); delete deviceTrails[deviceId]; } // Clear trail layers for a device (by safe ID) function clearDeviceTrailLayers(safeId) { if (!map3d || !map3dLoaded) return; const sourceId = `trail-source-${safeId}`; const layerId = `trail-layer-${safeId}`; const pointsLayerId = `trail-points-${safeId}`; // Remove layers first if (map3d.getLayer(layerId)) { map3d.removeLayer(layerId); } if (map3d.getLayer(pointsLayerId)) { map3d.removeLayer(pointsLayerId); } // Remove sources if (map3d.getSource(sourceId)) { map3d.removeSource(sourceId); } if (map3d.getSource(`${sourceId}-points`)) { map3d.removeSource(`${sourceId}-points`); } } // Clear all trails from the 3D map function clearAllTrails() { if (!map3d || !map3dLoaded) return; // Remove all trail layers and sources const style = map3d.getStyle(); if (!style || !style.layers) return; // Find and remove trail layers const layersToRemove = style.layers .filter(layer => layer.id.startsWith('trail-')) .map(layer => layer.id); layersToRemove.forEach(layerId => { if (map3d.getLayer(layerId)) { map3d.removeLayer(layerId); } }); // Find and remove trail sources const sources = Object.keys(style.sources || {}); sources.forEach(sourceId => { if (sourceId.startsWith('trail-')) { if (map3d.getSource(sourceId)) { map3d.removeSource(sourceId); } } }); deviceTrails = {}; }