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:
User
2026-02-01 10:33:57 +01:00
parent 5fbf096a04
commit 24de6c7f06
6 changed files with 448 additions and 6 deletions

View File

@@ -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');
}
}