- Scanner positions shown as magenta stars (this scanner) - Peer scanners shown as cyan stars - Clear visual distinction from WiFi (orange circles) and BT (blue circles) - Radar view also shows scanner as star shape Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2207 lines
76 KiB
JavaScript
2207 lines
76 KiB
JavaScript
/**
|
|
* 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 = '<div style="color:#888;padding:1rem;">No scans yet. Click "New Scan" to start.</div>';
|
|
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 => `
|
|
<div class="device-card wifi" data-bssid="${net.bssid}">
|
|
<div class="device-name">${escapeHtml(net.ssid)}</div>
|
|
<div class="manufacturer">${escapeHtml(net.manufacturer)}</div>
|
|
<div class="device-info">
|
|
<span>${net.rssi} dBm</span>
|
|
<span class="distance-badge">~${net.estimated_distance_m}m</span>
|
|
${signalBar(net.rssi)}
|
|
</div>
|
|
</div>
|
|
`).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 => `
|
|
<div class="device-card bluetooth" data-addr="${dev.address}">
|
|
<div class="device-header">
|
|
<div class="device-name">${escapeHtml(dev.name)}</div>
|
|
<button class="identify-btn" onclick="identifyDevice('${dev.address}', this)" title="Identify device">🔍</button>
|
|
</div>
|
|
<div class="manufacturer">${escapeHtml(dev.manufacturer)} - ${escapeHtml(dev.device_type)}</div>
|
|
<div class="device-services" id="services-${dev.address.replace(/:/g, '')}"></div>
|
|
<div class="device-info">
|
|
<span>${dev.rssi} dBm</span>
|
|
<span class="distance-badge">~${dev.estimated_distance_m}m</span>
|
|
${signalBar(dev.rssi)}
|
|
</div>
|
|
</div>
|
|
`).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 = `<div class="signal-bar ${cls}">`;
|
|
for (let i = 0; i < bars; i++) {
|
|
const h = 4 + i * 2;
|
|
html += `<span style="height:${h}px" class="${i < strength ? 'active' : ''}"></span>`;
|
|
}
|
|
html += '</div>';
|
|
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 = `
|
|
<div style="position:relative;width:24px;height:24px;">
|
|
<svg viewBox="0 0 24 24" style="width:24px;height:24px;filter:drop-shadow(0 0 4px rgba(255,0,128,0.8));">
|
|
<polygon points="12,2 15,9 22,9 17,14 19,22 12,17 5,22 7,14 2,9 9,9" fill="#ff0080" stroke="white" stroke-width="1.5"/>
|
|
</svg>
|
|
</div>`;
|
|
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 = `
|
|
<div style="position:relative;width:20px;height:20px;">
|
|
<svg viewBox="0 0 24 24" style="width:20px;height:20px;filter:drop-shadow(0 0 3px rgba(0,200,255,0.8));">
|
|
<polygon points="12,2 15,9 22,9 17,14 19,22 12,17 5,22 7,14 2,9 9,9" fill="#00c8ff" stroke="white" stroke-width="1.5"/>
|
|
</svg>
|
|
</div>`;
|
|
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}<br>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(`
|
|
<strong>📶 ${escapeHtml(net.ssid)}</strong><br>
|
|
Signal: ${net.rssi} dBm<br>
|
|
Distance: ${distLabel}<br>
|
|
Channel: ${net.channel}<br>
|
|
${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(`
|
|
<strong>🔵 ${escapeHtml(dev.name)}</strong><br>
|
|
Signal: ${dev.rssi} dBm<br>
|
|
Distance: ${distLabel}<br>
|
|
Type: ${escapeHtml(dev.device_type)}<br>
|
|
${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 !== '<unknown>') {
|
|
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 = `<span class="device-type-badge">${escapeHtml(data.device_type)}</span>`;
|
|
}
|
|
|
|
if (data.services && data.services.length > 0) {
|
|
servicesDiv.innerHTML = data.services
|
|
.slice(0, 6)
|
|
.map(s => `<span class="service-tag">${escapeHtml(s)}</span>`)
|
|
.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 = `<div class="marker-icon">📍</div><div class="marker-floor">F${scannerFloor}</div>`;
|
|
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(`
|
|
<strong>📍 Your Position</strong><br>
|
|
Floor: ${scannerFloor}<br>
|
|
Lat: ${lat.toFixed(6)}<br>
|
|
Lon: ${lon.toFixed(6)}<br>
|
|
<div style="font-size:0.7rem;color:#888;margin-top:4px;">Drag marker to reposition</div>
|
|
`))
|
|
.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 = `<div class="marker-icon">📶</div><div class="marker-floor">${floorLabel}</div>`;
|
|
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 ? `<button class="popup-reset-btn" onclick="resetDevicePosition('${wifiDeviceId}')">Reset to Auto</button>` : '';
|
|
const dragHint = isDraggable && !hasManualPosition ? '<div style="font-size:0.7rem;color:#888;margin-top:4px;">Drag marker to set position</div>' : '';
|
|
|
|
const popup = new maplibregl.Popup({ offset: 25 }).setHTML(`
|
|
<strong>📶 ${escapeHtml(net.ssid)}</strong><br>
|
|
Signal: ${net.rssi} dBm<br>
|
|
Distance: ${distLabel}<br>
|
|
Channel: ${net.channel}<br>
|
|
${escapeHtml(net.manufacturer)}<br>
|
|
<div class="popup-floor-control">
|
|
<label>Floor:</label>
|
|
<select onchange="updateDeviceFloor('${wifiDeviceId}', this.value)">
|
|
<option value="">Unknown</option>
|
|
${generateFloorOptions(deviceFloor)}
|
|
</select>
|
|
</div>
|
|
<div class="popup-floor-control">
|
|
<label>Dist (m):</label>
|
|
<input type="number" step="0.1" min="0" value="${hasCustomDist ? effectiveDist : ''}"
|
|
placeholder="${net.estimated_distance_m}"
|
|
onchange="updateDeviceDistance('${wifiDeviceId}', this.value)">
|
|
</div>
|
|
<div class="popup-position-status">
|
|
<span class="status-label">Position:</span>
|
|
<span class="status-value ${positionClass}">${positionStatus}</span>
|
|
</div>
|
|
${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 = `<div class="marker-icon">${isMoving ? '🟣' : '🔵'}</div><div class="marker-floor">${btFloorLabel}</div>`;
|
|
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 ? `<button class="popup-reset-btn" onclick="resetDevicePosition('${btDeviceId}')">Reset to Auto</button>` : '';
|
|
const dragHint = isDraggable && !hasManualPosition ? '<div style="font-size:0.7rem;color:#888;margin-top:4px;">Drag marker to set position</div>' : '';
|
|
const trailBtnHtml = isMoving ? `
|
|
<button class="popup-trail-btn" id="trail-btn-${btDeviceId.replace(/:/g, '')}"
|
|
onclick="toggleDeviceTrail('${btDeviceId}', '${escapeHtml(dev.name)}', 'bluetooth')">
|
|
Show Trail
|
|
</button>` : '';
|
|
|
|
const popup = new maplibregl.Popup({ offset: 25 }).setHTML(`
|
|
<strong>${isMoving ? '🟣' : '🔵'} ${escapeHtml(dev.name)}</strong>${isMoving ? ' <span style="color:#9b59b6;font-size:0.8em;">(Moving)</span>' : ''}<br>
|
|
Signal: ${dev.rssi} dBm<br>
|
|
Distance: ${btDistLabel}<br>
|
|
Type: ${escapeHtml(dev.device_type)}<br>
|
|
${escapeHtml(dev.manufacturer)}<br>
|
|
<div class="popup-floor-control">
|
|
<label>Floor:</label>
|
|
<select onchange="updateDeviceFloor('${btDeviceId}', this.value)">
|
|
<option value="">Unknown</option>
|
|
${generateFloorOptions(deviceFloor)}
|
|
</select>
|
|
</div>
|
|
<div class="popup-floor-control">
|
|
<label>Dist (m):</label>
|
|
<input type="number" step="0.1" min="0" value="${hasCustomDist ? effectiveDist : ''}"
|
|
placeholder="${dev.estimated_distance_m}"
|
|
onchange="updateDeviceDistance('${btDeviceId}', this.value)">
|
|
</div>
|
|
<div class="popup-position-status">
|
|
<span class="status-label">Position:</span>
|
|
<span class="status-value ${positionClass}">${positionStatus}</span>
|
|
</div>
|
|
${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 = '<option value="all">All Floors</option>';
|
|
|
|
// 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 += `<option value="${i}" ${selected}>${label}</option>`;
|
|
}
|
|
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 = {};
|
|
}
|