/**
* 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.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 = {};
}