feat: add multi-scanner trilateration and signal heat map
- Add database methods for multi-scanner RSSI queries - Add weighted trilateration function supporting 2+ scanners - Add /api/positions/trilaterated endpoint - Add /api/heatmap/signal endpoint for heat map data - Update frontend to show trilaterated positions with gold markers - Add heat map toggle button for signal coverage visualization Trilateration uses RSSI from multiple scanners to calculate device positions with confidence scores. Devices seen by 2+ scanners within 60 seconds get trilaterated positions shown with gold border markers. Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
@@ -24,6 +24,13 @@ let deviceSources = {}; // { deviceId: { scanner_id, lat, lon } }
|
||||
// Peer scanner positions - loaded from /api/peers (live positions)
|
||||
let peerScanners = {}; // { scanner_id: { lat, lon, floor, name } }
|
||||
|
||||
// Trilateration state - positions calculated from multiple scanner RSSI
|
||||
let trilateratedPositions = {}; // { deviceId: { lat, lon, confidence, scanners, method } }
|
||||
let trilaterationEnabled = true;
|
||||
|
||||
// Heat map state
|
||||
let heatMapEnabled = false;
|
||||
|
||||
// Auto-scan state
|
||||
let autoScanEnabled = false;
|
||||
let autoScanPollInterval = null;
|
||||
@@ -131,6 +138,7 @@ document.addEventListener('DOMContentLoaded', () => {
|
||||
loadLatestScan();
|
||||
loadAutoScanStatus();
|
||||
loadDevicePositions(); // Load saved manual positions
|
||||
loadTrilateratedPositions(); // Load multi-scanner trilaterated positions
|
||||
|
||||
// Initialize 3D map as default view
|
||||
setTimeout(() => {
|
||||
@@ -287,6 +295,9 @@ function handleWebSocketScanUpdate(data) {
|
||||
document.getElementById('bt-count').textContent = scanData.bluetooth_devices.length;
|
||||
document.getElementById('bt-list-count').textContent = scanData.bluetooth_devices.length;
|
||||
|
||||
// Refresh trilaterated positions (non-blocking)
|
||||
loadTrilateratedPositions();
|
||||
|
||||
// Refresh views
|
||||
drawRadar();
|
||||
update3DMarkers();
|
||||
@@ -1256,13 +1267,53 @@ async function loadDevicePositions() {
|
||||
}
|
||||
}
|
||||
|
||||
// Get device position (manual or RSSI-based)
|
||||
// Load trilaterated positions from multi-scanner RSSI data
|
||||
async function loadTrilateratedPositions() {
|
||||
if (!trilaterationEnabled) return;
|
||||
|
||||
try {
|
||||
const resp = await fetch('/api/positions/trilaterated');
|
||||
if (!resp.ok) return;
|
||||
|
||||
const data = await resp.json();
|
||||
trilateratedPositions = {};
|
||||
(data.devices || []).forEach(d => {
|
||||
trilateratedPositions[d.device_id] = {
|
||||
lat: d.position.lat,
|
||||
lon: d.position.lon,
|
||||
confidence: d.confidence,
|
||||
scanners: d.scanners,
|
||||
method: d.method
|
||||
};
|
||||
});
|
||||
console.log(`[Trilateration] Loaded ${data.count} positions`);
|
||||
} catch (e) {
|
||||
console.error('[Trilateration] Error:', e);
|
||||
}
|
||||
}
|
||||
|
||||
// Get device position (manual, trilaterated, or RSSI-based)
|
||||
// Priority: 1. Manual position, 2. Trilaterated (if confidence > 0.3), 3. RSSI-based
|
||||
// Uses source scanner position for synced devices so they don't move when local scanner moves
|
||||
function getDevicePosition(device, scannerLat, scannerLon, minDistanceM) {
|
||||
const deviceId = device.bssid || device.address;
|
||||
const customPos = manualPositions[deviceId];
|
||||
const sourceInfo = deviceSources[deviceId];
|
||||
|
||||
// Check for trilaterated position first (if enabled and confident)
|
||||
const triData = trilateratedPositions[deviceId];
|
||||
if (trilaterationEnabled && triData && triData.confidence > 0.3) {
|
||||
return {
|
||||
lat: triData.lat,
|
||||
lon: triData.lon,
|
||||
isManual: false,
|
||||
isTrilaterated: true,
|
||||
confidence: triData.confidence,
|
||||
scannerCount: triData.scanners.length,
|
||||
method: triData.method
|
||||
};
|
||||
}
|
||||
|
||||
// Determine which scanner position to use for this device
|
||||
// If device was synced from another scanner, use that scanner's CURRENT position
|
||||
let baseLat = scannerLat;
|
||||
@@ -1780,26 +1831,33 @@ function update3DMarkers() {
|
||||
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 isTrilaterated = pos.isTrilaterated === true;
|
||||
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');
|
||||
if (isTrilaterated) {
|
||||
el.classList.add('trilaterated');
|
||||
if (pos.confidence >= 0.7) el.classList.add('high-confidence');
|
||||
}
|
||||
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 trilatBadge = isTrilaterated ? `<span class="trilat-badge">${pos.scannerCount}📡</span>` : '';
|
||||
el.innerHTML = `<div class="marker-icon">${isMoving ? '🟣' : '🔵'}${trilatBadge}</div><div class="marker-floor">${btFloorLabel}</div>`;
|
||||
el.title = `${dev.name} - ${deviceFloor !== null ? 'Floor ' + deviceFloor : 'Unknown floor'}${isMoving ? ' (MOVING)' : ''}${isTrilaterated ? ` (Trilaterated: ${pos.scannerCount} scanners, ${Math.round(pos.confidence*100)}% conf)` : ''}${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 positionStatus = isTrilaterated ? `Trilaterated (${pos.scannerCount} scanners)` : (hasManualPosition ? 'Manual' : 'Auto');
|
||||
const positionClass = isTrilaterated ? 'trilaterated' : (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 dragHint = isDraggable && !hasManualPosition && !isTrilaterated ? '<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 btSourceInfo = pos.isRemoteSource ? `<div class="popup-source-info">📡 Source: ${pos.sourceScanner}</div>` : '';
|
||||
const trilatInfo = isTrilaterated ? `<div class="popup-source-info" style="background:rgba(255,215,0,0.1);border-color:rgba(255,215,0,0.3);color:#ffd700;">📐 ${pos.method}: ${Math.round(pos.confidence*100)}% confidence</div>` : '';
|
||||
|
||||
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>
|
||||
@@ -1808,6 +1866,7 @@ function update3DMarkers() {
|
||||
Type: ${escapeHtml(dev.device_type)}<br>
|
||||
${escapeHtml(dev.manufacturer)}<br>
|
||||
${btSourceInfo}
|
||||
${trilatInfo}
|
||||
<div class="popup-floor-control">
|
||||
<label>Floor:</label>
|
||||
<select onchange="updateDeviceFloor('${btDeviceId}', this.value)">
|
||||
@@ -2221,6 +2280,9 @@ async function performLiveBTScan() {
|
||||
document.getElementById('bt-count').textContent = scanData.bluetooth_devices.length;
|
||||
document.getElementById('bt-list-count').textContent = scanData.bluetooth_devices.length;
|
||||
|
||||
// Refresh trilaterated positions (non-blocking)
|
||||
loadTrilateratedPositions();
|
||||
|
||||
// Refresh views
|
||||
drawRadar();
|
||||
update3DMarkers();
|
||||
@@ -2477,3 +2539,85 @@ function clearAllTrails() {
|
||||
|
||||
deviceTrails = {};
|
||||
}
|
||||
|
||||
// ========== Heat Map Functions ==========
|
||||
|
||||
// Toggle heat map layer
|
||||
function toggleHeatMap() {
|
||||
heatMapEnabled = !heatMapEnabled;
|
||||
const btn = document.getElementById('btn-heatmap');
|
||||
if (btn) btn.classList.toggle('active', heatMapEnabled);
|
||||
|
||||
if (heatMapEnabled) {
|
||||
renderHeatMap();
|
||||
} else {
|
||||
removeHeatMap();
|
||||
}
|
||||
}
|
||||
|
||||
// Render heat map layer on 3D map
|
||||
async function renderHeatMap() {
|
||||
if (!map3d || !map3dLoaded) {
|
||||
console.log('[HeatMap] 3D map not ready');
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
const resp = await fetch('/api/heatmap/signal');
|
||||
if (!resp.ok) return;
|
||||
|
||||
const data = await resp.json();
|
||||
console.log(`[HeatMap] Loaded ${data.count} points`);
|
||||
|
||||
// Remove existing heat map if present
|
||||
removeHeatMap();
|
||||
|
||||
// Add heat map source
|
||||
map3d.addSource('signal-heatmap', {
|
||||
type: 'geojson',
|
||||
data: {
|
||||
type: 'FeatureCollection',
|
||||
features: data.points.map(p => ({
|
||||
type: 'Feature',
|
||||
geometry: { type: 'Point', coordinates: [p.lon, p.lat] },
|
||||
properties: { weight: p.weight }
|
||||
}))
|
||||
}
|
||||
});
|
||||
|
||||
// Add heat map layer
|
||||
map3d.addLayer({
|
||||
id: 'signal-heatmap-layer',
|
||||
type: 'heatmap',
|
||||
source: 'signal-heatmap',
|
||||
paint: {
|
||||
'heatmap-weight': ['get', 'weight'],
|
||||
'heatmap-intensity': 0.6,
|
||||
'heatmap-radius': 40,
|
||||
'heatmap-opacity': 0.6,
|
||||
'heatmap-color': [
|
||||
'interpolate', ['linear'], ['heatmap-density'],
|
||||
0, 'rgba(0,0,255,0)',
|
||||
0.3, 'rgba(0,255,255,0.5)',
|
||||
0.5, 'rgba(0,255,0,0.6)',
|
||||
0.7, 'rgba(255,255,0,0.7)',
|
||||
1, 'rgba(255,0,0,0.8)'
|
||||
]
|
||||
}
|
||||
});
|
||||
} catch (e) {
|
||||
console.error('[HeatMap] Error:', e);
|
||||
}
|
||||
}
|
||||
|
||||
// Remove heat map layer from 3D map
|
||||
function removeHeatMap() {
|
||||
if (!map3d) return;
|
||||
|
||||
if (map3d.getLayer('signal-heatmap-layer')) {
|
||||
map3d.removeLayer('signal-heatmap-layer');
|
||||
}
|
||||
if (map3d.getSource('signal-heatmap')) {
|
||||
map3d.removeSource('signal-heatmap');
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user