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
|
||||
)
|
||||
|
||||
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,
|
||||
since: Optional[str] = None,
|
||||
limit: int = 100) -> list[dict]:
|
||||
|
||||
@@ -118,3 +118,85 @@ def trilaterate_2d(
|
||||
y = (A * F - D * C) / denom
|
||||
|
||||
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)
|
||||
|
||||
# ==================== 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")
|
||||
def api_history_stats():
|
||||
"""Get database statistics"""
|
||||
|
||||
@@ -832,6 +832,10 @@ body {
|
||||
color: var(--color-primary);
|
||||
}
|
||||
|
||||
.popup-position-status .status-value.trilaterated {
|
||||
color: #ffd700;
|
||||
}
|
||||
|
||||
.popup-source-info {
|
||||
margin-top: 6px;
|
||||
padding: 4px 8px;
|
||||
@@ -974,6 +978,39 @@ body {
|
||||
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-section {
|
||||
display: block;
|
||||
|
||||
@@ -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');
|
||||
}
|
||||
}
|
||||
|
||||
@@ -33,6 +33,9 @@
|
||||
<span class="filter-indicator"></span>
|
||||
<span>Bluetooth</span>
|
||||
</button>
|
||||
<button id="btn-heatmap" class="filter-btn heatmap" onclick="toggleHeatMap()">
|
||||
<span>🌡️ Heat Map</span>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<canvas id="radar-canvas" class="radar-canvas"></canvas>
|
||||
|
||||
Reference in New Issue
Block a user