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:
@@ -496,6 +496,56 @@ class DeviceDatabase:
|
|||||||
max_distance_m=round(stats['max_distance_m'], 2) if stats['max_distance_m'] else 0
|
max_distance_m=round(stats['max_distance_m'], 2) if stats['max_distance_m'] else 0
|
||||||
)
|
)
|
||||||
|
|
||||||
|
def get_device_multi_scanner_rssi(self, device_id: str, seconds: int = 60) -> list[dict]:
|
||||||
|
"""Get recent RSSI readings per scanner for a device.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
device_id: The device MAC address
|
||||||
|
seconds: Time window in seconds (default 60)
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
List of dicts with scanner_id, avg_rssi, sample_count, last_seen
|
||||||
|
"""
|
||||||
|
conn = self._get_connection()
|
||||||
|
cursor = conn.cursor()
|
||||||
|
|
||||||
|
query = """
|
||||||
|
SELECT scanner_id,
|
||||||
|
AVG(rssi) as avg_rssi,
|
||||||
|
COUNT(*) as sample_count,
|
||||||
|
MAX(timestamp) as last_seen
|
||||||
|
FROM rssi_history
|
||||||
|
WHERE device_id = ?
|
||||||
|
AND scanner_id IS NOT NULL
|
||||||
|
AND timestamp >= datetime('now', '-' || ? || ' seconds')
|
||||||
|
GROUP BY scanner_id
|
||||||
|
"""
|
||||||
|
cursor.execute(query, (device_id, seconds))
|
||||||
|
return [dict(r) for r in cursor.fetchall()]
|
||||||
|
|
||||||
|
def get_devices_seen_by_multiple_scanners(self, seconds: int = 60) -> list[str]:
|
||||||
|
"""Get device IDs seen by 2+ scanners recently.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
seconds: Time window in seconds (default 60)
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
List of device IDs seen by multiple scanners
|
||||||
|
"""
|
||||||
|
conn = self._get_connection()
|
||||||
|
cursor = conn.cursor()
|
||||||
|
|
||||||
|
query = """
|
||||||
|
SELECT device_id
|
||||||
|
FROM rssi_history
|
||||||
|
WHERE scanner_id IS NOT NULL
|
||||||
|
AND timestamp >= datetime('now', '-' || ? || ' seconds')
|
||||||
|
GROUP BY device_id
|
||||||
|
HAVING COUNT(DISTINCT scanner_id) >= 2
|
||||||
|
"""
|
||||||
|
cursor.execute(query, (seconds,))
|
||||||
|
return [r['device_id'] for r in cursor.fetchall()]
|
||||||
|
|
||||||
def get_movement_events(self, device_id: Optional[str] = None,
|
def get_movement_events(self, device_id: Optional[str] = None,
|
||||||
since: Optional[str] = None,
|
since: Optional[str] = None,
|
||||||
limit: int = 100) -> list[dict]:
|
limit: int = 100) -> list[dict]:
|
||||||
|
|||||||
@@ -118,3 +118,85 @@ def trilaterate_2d(
|
|||||||
y = (A * F - D * C) / denom
|
y = (A * F - D * C) / denom
|
||||||
|
|
||||||
return (x, y)
|
return (x, y)
|
||||||
|
|
||||||
|
|
||||||
|
def trilaterate_weighted_latlon(
|
||||||
|
scanner_data: list[dict]
|
||||||
|
) -> tuple[float, float, float] | None:
|
||||||
|
"""
|
||||||
|
Weighted trilateration using lat/lon coordinates.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
scanner_data: List of dicts with:
|
||||||
|
- lat: Scanner latitude
|
||||||
|
- lon: Scanner longitude
|
||||||
|
- distance: Estimated distance in meters
|
||||||
|
- rssi: Signal strength (for weighting)
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
(lat, lon, confidence) or None if insufficient data
|
||||||
|
"""
|
||||||
|
if len(scanner_data) < 2:
|
||||||
|
return None
|
||||||
|
|
||||||
|
# Calculate weights from RSSI (higher = better)
|
||||||
|
for s in scanner_data:
|
||||||
|
s['weight'] = 10 ** ((s['rssi'] + 100) / 40) # Normalize -100 to 0 range
|
||||||
|
|
||||||
|
total_weight = sum(s['weight'] for s in scanner_data)
|
||||||
|
|
||||||
|
if len(scanner_data) == 2:
|
||||||
|
# Two scanners: weighted average along line between them
|
||||||
|
s1, s2 = scanner_data[0], scanner_data[1]
|
||||||
|
|
||||||
|
# Calculate position along line based on distance ratio
|
||||||
|
total_dist = s1['distance'] + s2['distance']
|
||||||
|
if total_dist == 0:
|
||||||
|
ratio = 0.5
|
||||||
|
else:
|
||||||
|
ratio = s1['distance'] / total_dist
|
||||||
|
|
||||||
|
# Weighted interpolation
|
||||||
|
lat = s1['lat'] + ratio * (s2['lat'] - s1['lat'])
|
||||||
|
lon = s1['lon'] + ratio * (s2['lon'] - s1['lon'])
|
||||||
|
|
||||||
|
# Lower confidence for 2-scanner solution
|
||||||
|
confidence = 0.5
|
||||||
|
return (lat, lon, confidence)
|
||||||
|
|
||||||
|
# 3+ scanners: convert to local XY and trilaterate
|
||||||
|
# Use centroid as origin
|
||||||
|
center_lat = sum(s['lat'] for s in scanner_data) / len(scanner_data)
|
||||||
|
center_lon = sum(s['lon'] for s in scanner_data) / len(scanner_data)
|
||||||
|
|
||||||
|
# Convert to meters from centroid
|
||||||
|
positions = []
|
||||||
|
distances = []
|
||||||
|
for s in scanner_data:
|
||||||
|
x = (s['lon'] - center_lon) * 111000 * math.cos(math.radians(center_lat))
|
||||||
|
y = (s['lat'] - center_lat) * 111000
|
||||||
|
positions.append((x, y))
|
||||||
|
distances.append(s['distance'])
|
||||||
|
|
||||||
|
# Use existing trilaterate_2d
|
||||||
|
result = trilaterate_2d(positions, distances)
|
||||||
|
if result is None:
|
||||||
|
return None
|
||||||
|
|
||||||
|
x, y = result
|
||||||
|
|
||||||
|
# Convert back to lat/lon
|
||||||
|
lat = center_lat + (y / 111000)
|
||||||
|
lon = center_lon + (x / (111000 * math.cos(math.radians(center_lat))))
|
||||||
|
|
||||||
|
# Calculate confidence based on residuals
|
||||||
|
residuals = []
|
||||||
|
for i, s in enumerate(scanner_data):
|
||||||
|
calc_dist = math.sqrt((x - positions[i][0])**2 + (y - positions[i][1])**2)
|
||||||
|
residuals.append(abs(calc_dist - distances[i]))
|
||||||
|
|
||||||
|
avg_residual = sum(residuals) / len(residuals)
|
||||||
|
avg_distance = sum(distances) / len(distances)
|
||||||
|
confidence = max(0, min(1, 1 - (avg_residual / max(avg_distance, 1))))
|
||||||
|
|
||||||
|
return (lat, lon, confidence)
|
||||||
|
|||||||
@@ -1294,6 +1294,132 @@ def create_app(config: Config | None = None) -> Flask:
|
|||||||
|
|
||||||
return jsonify(activity)
|
return jsonify(activity)
|
||||||
|
|
||||||
|
# ==================== Trilateration API ====================
|
||||||
|
|
||||||
|
@app.route("/api/positions/trilaterated")
|
||||||
|
def api_trilaterated_positions():
|
||||||
|
"""Get trilaterated positions for devices seen by multiple scanners."""
|
||||||
|
from ..distance import trilaterate_weighted_latlon, estimate_distance
|
||||||
|
|
||||||
|
db = app.config.get("DATABASE")
|
||||||
|
if not db:
|
||||||
|
return jsonify({"error": "Database not enabled"}), 503
|
||||||
|
|
||||||
|
seconds = int(request.args.get("seconds", 60))
|
||||||
|
|
||||||
|
# Get devices seen by multiple scanners
|
||||||
|
device_ids = db.get_devices_seen_by_multiple_scanners(seconds=seconds)
|
||||||
|
|
||||||
|
# Get all scanner positions (local + peers)
|
||||||
|
scanner_positions = {}
|
||||||
|
|
||||||
|
# Local scanner
|
||||||
|
local = app.config.get("SCANNER_IDENTITY", {})
|
||||||
|
if local.get("id"):
|
||||||
|
scanner_positions[local["id"]] = {
|
||||||
|
"lat": local.get("latitude"),
|
||||||
|
"lon": local.get("longitude")
|
||||||
|
}
|
||||||
|
|
||||||
|
# Peer scanners
|
||||||
|
peers = db.get_peers()
|
||||||
|
for peer in peers:
|
||||||
|
scanner_positions[peer["scanner_id"]] = {
|
||||||
|
"lat": peer.get("latitude"),
|
||||||
|
"lon": peer.get("longitude")
|
||||||
|
}
|
||||||
|
|
||||||
|
results = []
|
||||||
|
for device_id in device_ids:
|
||||||
|
scanner_rssi = db.get_device_multi_scanner_rssi(device_id, seconds=seconds)
|
||||||
|
|
||||||
|
# Build scanner data for trilateration
|
||||||
|
scanner_data = []
|
||||||
|
for sr in scanner_rssi:
|
||||||
|
sid = sr["scanner_id"]
|
||||||
|
if sid in scanner_positions and scanner_positions[sid].get("lat"):
|
||||||
|
pos = scanner_positions[sid]
|
||||||
|
dist = estimate_distance(int(sr["avg_rssi"]), tx_power=-65)
|
||||||
|
scanner_data.append({
|
||||||
|
"scanner_id": sid,
|
||||||
|
"lat": pos["lat"],
|
||||||
|
"lon": pos["lon"],
|
||||||
|
"distance": dist,
|
||||||
|
"rssi": sr["avg_rssi"]
|
||||||
|
})
|
||||||
|
|
||||||
|
if len(scanner_data) < 2:
|
||||||
|
continue
|
||||||
|
|
||||||
|
# Calculate trilaterated position
|
||||||
|
result = trilaterate_weighted_latlon(scanner_data)
|
||||||
|
if result:
|
||||||
|
lat, lon, confidence = result
|
||||||
|
results.append({
|
||||||
|
"device_id": device_id,
|
||||||
|
"position": {"lat": lat, "lon": lon},
|
||||||
|
"confidence": round(confidence, 2),
|
||||||
|
"scanners": [s["scanner_id"] for s in scanner_data],
|
||||||
|
"method": "trilateration" if len(scanner_data) >= 3 else "weighted_average"
|
||||||
|
})
|
||||||
|
|
||||||
|
return jsonify({"devices": results, "count": len(results)})
|
||||||
|
|
||||||
|
@app.route("/api/heatmap/signal")
|
||||||
|
def api_signal_heatmap():
|
||||||
|
"""Get signal strength data for heat map visualization."""
|
||||||
|
db = app.config.get("DATABASE")
|
||||||
|
if not db:
|
||||||
|
return jsonify({"error": "Database not enabled"}), 503
|
||||||
|
|
||||||
|
floor = request.args.get("floor")
|
||||||
|
|
||||||
|
# Get scanner positions as anchor points (max signal)
|
||||||
|
points = []
|
||||||
|
|
||||||
|
# Local scanner
|
||||||
|
local = app.config.get("SCANNER_IDENTITY", {})
|
||||||
|
if local.get("latitude"):
|
||||||
|
points.append({
|
||||||
|
"lat": local["latitude"],
|
||||||
|
"lon": local["longitude"],
|
||||||
|
"signal": -30, # Scanner location = max signal
|
||||||
|
"weight": 1.0
|
||||||
|
})
|
||||||
|
|
||||||
|
# Peer scanners
|
||||||
|
peers = db.get_peers()
|
||||||
|
for peer in peers:
|
||||||
|
if peer.get("latitude"):
|
||||||
|
points.append({
|
||||||
|
"lat": peer["latitude"],
|
||||||
|
"lon": peer["longitude"],
|
||||||
|
"signal": -30,
|
||||||
|
"weight": 1.0
|
||||||
|
})
|
||||||
|
|
||||||
|
# Get recent device observations with positions
|
||||||
|
# Use trilaterated positions if available, otherwise source scanner positions
|
||||||
|
devices = db.get_all_devices(device_type="bluetooth", limit=100)
|
||||||
|
for dev in devices:
|
||||||
|
# Get most recent RSSI for this device
|
||||||
|
rssi_history = db.get_device_rssi_history(dev["device_id"], limit=1)
|
||||||
|
if rssi_history:
|
||||||
|
last_rssi = rssi_history[0].rssi
|
||||||
|
# Get device source info
|
||||||
|
if dev.get("source_scanner_lat") and dev.get("source_scanner_lon"):
|
||||||
|
# Calculate rough position from source scanner
|
||||||
|
from ..distance import estimate_distance
|
||||||
|
dist = estimate_distance(last_rssi, tx_power=-65)
|
||||||
|
points.append({
|
||||||
|
"lat": dev["source_scanner_lat"],
|
||||||
|
"lon": dev["source_scanner_lon"],
|
||||||
|
"signal": last_rssi,
|
||||||
|
"weight": max(0.1, min(0.8, 1 - (dist / 50))) # Weight by distance
|
||||||
|
})
|
||||||
|
|
||||||
|
return jsonify({"points": points, "count": len(points)})
|
||||||
|
|
||||||
@app.route("/api/history/stats")
|
@app.route("/api/history/stats")
|
||||||
def api_history_stats():
|
def api_history_stats():
|
||||||
"""Get database statistics"""
|
"""Get database statistics"""
|
||||||
|
|||||||
@@ -832,6 +832,10 @@ body {
|
|||||||
color: var(--color-primary);
|
color: var(--color-primary);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.popup-position-status .status-value.trilaterated {
|
||||||
|
color: #ffd700;
|
||||||
|
}
|
||||||
|
|
||||||
.popup-source-info {
|
.popup-source-info {
|
||||||
margin-top: 6px;
|
margin-top: 6px;
|
||||||
padding: 4px 8px;
|
padding: 4px 8px;
|
||||||
@@ -974,6 +978,39 @@ body {
|
|||||||
background: rgba(0, 200, 255, 0.9);
|
background: rgba(0, 200, 255, 0.9);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* Trilaterated device markers - gold border */
|
||||||
|
.marker-3d.trilaterated .marker-icon {
|
||||||
|
border: 2px dashed #ffd700 !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
.marker-3d.trilaterated.high-confidence .marker-icon {
|
||||||
|
border-style: solid !important;
|
||||||
|
box-shadow: 0 0 10px rgba(255, 215, 0, 0.7), 0 2px 8px rgba(0, 0, 0, 0.5) !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
.trilat-badge {
|
||||||
|
position: absolute;
|
||||||
|
top: -6px;
|
||||||
|
right: -6px;
|
||||||
|
background: #ffd700;
|
||||||
|
color: #000;
|
||||||
|
font-size: 7px;
|
||||||
|
padding: 1px 3px;
|
||||||
|
border-radius: 3px;
|
||||||
|
font-weight: bold;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Heat map toggle button */
|
||||||
|
.filter-btn.heatmap {
|
||||||
|
color: #ff6b6b;
|
||||||
|
border-color: #ff6b6b;
|
||||||
|
}
|
||||||
|
|
||||||
|
.filter-btn.heatmap.active {
|
||||||
|
background: rgba(255, 107, 107, 0.2);
|
||||||
|
color: #ff6b6b;
|
||||||
|
}
|
||||||
|
|
||||||
/* Floor Controls */
|
/* Floor Controls */
|
||||||
.floor-section {
|
.floor-section {
|
||||||
display: block;
|
display: block;
|
||||||
|
|||||||
@@ -24,6 +24,13 @@ let deviceSources = {}; // { deviceId: { scanner_id, lat, lon } }
|
|||||||
// Peer scanner positions - loaded from /api/peers (live positions)
|
// Peer scanner positions - loaded from /api/peers (live positions)
|
||||||
let peerScanners = {}; // { scanner_id: { lat, lon, floor, name } }
|
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
|
// Auto-scan state
|
||||||
let autoScanEnabled = false;
|
let autoScanEnabled = false;
|
||||||
let autoScanPollInterval = null;
|
let autoScanPollInterval = null;
|
||||||
@@ -131,6 +138,7 @@ document.addEventListener('DOMContentLoaded', () => {
|
|||||||
loadLatestScan();
|
loadLatestScan();
|
||||||
loadAutoScanStatus();
|
loadAutoScanStatus();
|
||||||
loadDevicePositions(); // Load saved manual positions
|
loadDevicePositions(); // Load saved manual positions
|
||||||
|
loadTrilateratedPositions(); // Load multi-scanner trilaterated positions
|
||||||
|
|
||||||
// Initialize 3D map as default view
|
// Initialize 3D map as default view
|
||||||
setTimeout(() => {
|
setTimeout(() => {
|
||||||
@@ -287,6 +295,9 @@ function handleWebSocketScanUpdate(data) {
|
|||||||
document.getElementById('bt-count').textContent = scanData.bluetooth_devices.length;
|
document.getElementById('bt-count').textContent = scanData.bluetooth_devices.length;
|
||||||
document.getElementById('bt-list-count').textContent = scanData.bluetooth_devices.length;
|
document.getElementById('bt-list-count').textContent = scanData.bluetooth_devices.length;
|
||||||
|
|
||||||
|
// Refresh trilaterated positions (non-blocking)
|
||||||
|
loadTrilateratedPositions();
|
||||||
|
|
||||||
// Refresh views
|
// Refresh views
|
||||||
drawRadar();
|
drawRadar();
|
||||||
update3DMarkers();
|
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
|
// Uses source scanner position for synced devices so they don't move when local scanner moves
|
||||||
function getDevicePosition(device, scannerLat, scannerLon, minDistanceM) {
|
function getDevicePosition(device, scannerLat, scannerLon, minDistanceM) {
|
||||||
const deviceId = device.bssid || device.address;
|
const deviceId = device.bssid || device.address;
|
||||||
const customPos = manualPositions[deviceId];
|
const customPos = manualPositions[deviceId];
|
||||||
const sourceInfo = deviceSources[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
|
// Determine which scanner position to use for this device
|
||||||
// If device was synced from another scanner, use that scanner's CURRENT position
|
// If device was synced from another scanner, use that scanner's CURRENT position
|
||||||
let baseLat = scannerLat;
|
let baseLat = scannerLat;
|
||||||
@@ -1780,26 +1831,33 @@ function update3DMarkers() {
|
|||||||
const missCount = dev.miss_count || 0;
|
const missCount = dev.miss_count || 0;
|
||||||
// Calculate opacity: 1.0 -> 0.6 -> 0.3 based on miss count
|
// 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 opacity = missCount === 0 ? 1.0 : (missCount === 1 ? 0.6 : 0.3);
|
||||||
|
const isTrilaterated = pos.isTrilaterated === true;
|
||||||
const el = document.createElement('div');
|
const el = document.createElement('div');
|
||||||
el.className = `marker-3d bluetooth${isMoving ? ' moving' : ''}`;
|
el.className = `marker-3d bluetooth${isMoving ? ' moving' : ''}`;
|
||||||
if (isDraggable) el.classList.add('draggable');
|
if (isDraggable) el.classList.add('draggable');
|
||||||
if (hasManualPosition) el.classList.add('has-manual-position');
|
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.opacity = opacity;
|
||||||
el.style.transition = 'opacity 0.5s ease';
|
el.style.transition = 'opacity 0.5s ease';
|
||||||
el.innerHTML = `<div class="marker-icon">${isMoving ? '🟣' : '🔵'}</div><div class="marker-floor">${btFloorLabel}</div>`;
|
const trilatBadge = isTrilaterated ? `<span class="trilat-badge">${pos.scannerCount}📡</span>` : '';
|
||||||
el.title = `${dev.name} - ${deviceFloor !== null ? 'Floor ' + deviceFloor : 'Unknown floor'}${isMoving ? ' (MOVING)' : ''}${hasManualPosition ? ' (Manual position)' : ''}${missCount > 0 ? ` (fading: ${missCount}/${MAX_MISSED_SCANS})` : ''}`;
|
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 btDistLabel = hasCustomDist ? `${effectiveDist}m (custom)` : `~${dev.estimated_distance_m}m`;
|
||||||
const positionStatus = hasManualPosition ? 'Manual' : 'Auto';
|
const positionStatus = isTrilaterated ? `Trilaterated (${pos.scannerCount} scanners)` : (hasManualPosition ? 'Manual' : 'Auto');
|
||||||
const positionClass = 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 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 ? `
|
const trailBtnHtml = isMoving ? `
|
||||||
<button class="popup-trail-btn" id="trail-btn-${btDeviceId.replace(/:/g, '')}"
|
<button class="popup-trail-btn" id="trail-btn-${btDeviceId.replace(/:/g, '')}"
|
||||||
onclick="toggleDeviceTrail('${btDeviceId}', '${escapeHtml(dev.name)}', 'bluetooth')">
|
onclick="toggleDeviceTrail('${btDeviceId}', '${escapeHtml(dev.name)}', 'bluetooth')">
|
||||||
Show Trail
|
Show Trail
|
||||||
</button>` : '';
|
</button>` : '';
|
||||||
const btSourceInfo = pos.isRemoteSource ? `<div class="popup-source-info">📡 Source: ${pos.sourceScanner}</div>` : '';
|
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(`
|
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>
|
<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>
|
Type: ${escapeHtml(dev.device_type)}<br>
|
||||||
${escapeHtml(dev.manufacturer)}<br>
|
${escapeHtml(dev.manufacturer)}<br>
|
||||||
${btSourceInfo}
|
${btSourceInfo}
|
||||||
|
${trilatInfo}
|
||||||
<div class="popup-floor-control">
|
<div class="popup-floor-control">
|
||||||
<label>Floor:</label>
|
<label>Floor:</label>
|
||||||
<select onchange="updateDeviceFloor('${btDeviceId}', this.value)">
|
<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-count').textContent = scanData.bluetooth_devices.length;
|
||||||
document.getElementById('bt-list-count').textContent = scanData.bluetooth_devices.length;
|
document.getElementById('bt-list-count').textContent = scanData.bluetooth_devices.length;
|
||||||
|
|
||||||
|
// Refresh trilaterated positions (non-blocking)
|
||||||
|
loadTrilateratedPositions();
|
||||||
|
|
||||||
// Refresh views
|
// Refresh views
|
||||||
drawRadar();
|
drawRadar();
|
||||||
update3DMarkers();
|
update3DMarkers();
|
||||||
@@ -2477,3 +2539,85 @@ function clearAllTrails() {
|
|||||||
|
|
||||||
deviceTrails = {};
|
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');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -33,6 +33,9 @@
|
|||||||
<span class="filter-indicator"></span>
|
<span class="filter-indicator"></span>
|
||||||
<span>Bluetooth</span>
|
<span>Bluetooth</span>
|
||||||
</button>
|
</button>
|
||||||
|
<button id="btn-heatmap" class="filter-btn heatmap" onclick="toggleHeatMap()">
|
||||||
|
<span>🌡️ Heat Map</span>
|
||||||
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<canvas id="radar-canvas" class="radar-canvas"></canvas>
|
<canvas id="radar-canvas" class="radar-canvas"></canvas>
|
||||||
|
|||||||
Reference in New Issue
Block a user