diff --git a/static/css/modes/bt_locate.css b/static/css/modes/bt_locate.css
index abaf712..52eda01 100644
--- a/static/css/modes/bt_locate.css
+++ b/static/css/modes/bt_locate.css
@@ -163,12 +163,29 @@
margin-top: 2px;
}
-.btl-hud-controls {
- display: flex;
- flex-direction: column;
- gap: 6px;
- flex-shrink: 0;
-}
+.btl-hud-controls {
+ display: flex;
+ flex-direction: column;
+ gap: 6px;
+ flex-shrink: 0;
+}
+
+.btl-hud-export-row {
+ display: flex;
+ gap: 5px;
+ align-items: center;
+}
+
+.btl-hud-export-format {
+ min-width: 62px;
+ padding: 3px 6px;
+ font-size: 10px;
+ font-family: var(--font-mono);
+ color: var(--text-secondary);
+ background: rgba(0, 0, 0, 0.45);
+ border: 1px solid rgba(255, 255, 255, 0.12);
+ border-radius: 4px;
+}
.btl-hud-audio-toggle {
display: flex;
@@ -253,19 +270,108 @@
padding: 8px;
}
-.btl-map-container {
- flex: 1;
- min-height: 250px;
- border-radius: 8px;
- overflow: hidden;
- border: 1px solid rgba(255, 255, 255, 0.1);
-}
-
+.btl-map-container {
+ flex: 1;
+ min-height: 250px;
+ position: relative;
+ border-radius: 8px;
+ overflow: hidden;
+ border: 1px solid rgba(255, 255, 255, 0.1);
+}
+
#btLocateMap {
width: 100%;
- height: 100%;
- background: #1a1a2e;
-}
+ height: 100%;
+ background: #1a1a2e;
+}
+
+.btl-map-overlay-controls {
+ position: absolute;
+ top: 10px;
+ right: 10px;
+ z-index: 450;
+ display: flex;
+ flex-direction: column;
+ gap: 4px;
+ padding: 7px 8px;
+ border-radius: 7px;
+ background: rgba(0, 0, 0, 0.6);
+ border: 1px solid rgba(255, 255, 255, 0.15);
+ backdrop-filter: blur(4px);
+}
+
+.btl-map-overlay-toggle {
+ display: flex;
+ align-items: center;
+ gap: 5px;
+ font-size: 10px;
+ color: var(--text-secondary);
+ font-family: var(--font-mono);
+ cursor: pointer;
+ white-space: nowrap;
+}
+
+.btl-map-overlay-toggle input[type="checkbox"] {
+ margin: 0;
+}
+
+.btl-map-overlay-toggle input[type="checkbox"]:disabled + span {
+ opacity: 0.45;
+}
+
+.btl-map-heat-legend {
+ position: absolute;
+ left: 10px;
+ bottom: 10px;
+ z-index: 430;
+ min-width: 120px;
+ padding: 6px 8px;
+ border-radius: 7px;
+ background: rgba(0, 0, 0, 0.6);
+ border: 1px solid rgba(255, 255, 255, 0.14);
+ backdrop-filter: blur(4px);
+}
+
+.btl-map-heat-label {
+ display: block;
+ font-size: 9px;
+ color: var(--text-dim);
+ text-transform: uppercase;
+ letter-spacing: 0.7px;
+ margin-bottom: 4px;
+}
+
+.btl-map-heat-bar {
+ height: 7px;
+ border-radius: 4px;
+ background: linear-gradient(90deg, #2563eb 0%, #16a34a 40%, #f59e0b 70%, #ef4444 100%);
+ border: 1px solid rgba(255, 255, 255, 0.15);
+}
+
+.btl-map-heat-scale {
+ display: flex;
+ justify-content: space-between;
+ margin-top: 3px;
+ font-size: 8px;
+ color: var(--text-dim);
+ text-transform: uppercase;
+ letter-spacing: 0.5px;
+}
+
+.btl-map-track-stats {
+ position: absolute;
+ right: 10px;
+ bottom: 10px;
+ z-index: 430;
+ padding: 5px 8px;
+ border-radius: 7px;
+ background: rgba(0, 0, 0, 0.6);
+ border: 1px solid rgba(255, 255, 255, 0.14);
+ color: var(--text-secondary);
+ font-size: 10px;
+ font-family: var(--font-mono);
+ backdrop-filter: blur(4px);
+}
.btl-rssi-chart-container {
height: 100px;
@@ -405,7 +511,7 @@
RESPONSIVE — stack HUD vertically on narrow
============================================ */
-@media (max-width: 900px) {
+@media (max-width: 900px) {
.btl-hud {
flex-wrap: wrap;
gap: 10px;
@@ -422,9 +528,33 @@
justify-content: space-around;
}
- .btl-hud-controls {
- flex-direction: row;
- width: 100%;
- justify-content: center;
- }
-}
+ .btl-hud-controls {
+ flex-direction: row;
+ width: 100%;
+ justify-content: center;
+ flex-wrap: wrap;
+ }
+
+ .btl-hud-export-row {
+ width: 100%;
+ justify-content: center;
+ }
+
+ .btl-map-overlay-controls {
+ top: 8px;
+ right: 8px;
+ gap: 3px;
+ padding: 6px 7px;
+ }
+
+ .btl-map-heat-legend {
+ left: 8px;
+ bottom: 8px;
+ }
+
+ .btl-map-track-stats {
+ right: 8px;
+ bottom: 8px;
+ font-size: 9px;
+ }
+}
diff --git a/static/js/modes/bt_locate.js b/static/js/modes/bt_locate.js
index c17327f..1dfbd71 100644
--- a/static/js/modes/bt_locate.js
+++ b/static/js/modes/bt_locate.js
@@ -5,11 +5,12 @@
const BtLocate = (function() {
'use strict';
- let eventSource = null;
- let map = null;
- let mapMarkers = [];
- let trailLine = null;
- let rssiHistory = [];
+ let eventSource = null;
+ let map = null;
+ let mapMarkers = [];
+ let trailPoints = [];
+ let trailLine = null;
+ let rssiHistory = [];
const MAX_RSSI_POINTS = 60;
let chartCanvas = null;
let chartCtx = null;
@@ -21,16 +22,56 @@ const BtLocate = (function() {
let handoffData = null;
let pollTimer = null;
let durationTimer = null;
- let sessionStartedAt = null;
- let lastDetectionCount = 0;
- let gpsLocked = false;
-
- function init() {
- if (initialized) {
- // Re-invalidate map on re-entry and ensure tiles are present
- if (map) {
- setTimeout(() => {
- map.invalidateSize();
+ let sessionStartedAt = null;
+ let lastDetectionCount = 0;
+ let gpsLocked = false;
+ let heatLayer = null;
+ let heatPoints = [];
+ let movementStartMarker = null;
+ let movementHeadMarker = null;
+ let strongestMarker = null;
+ let confidenceCircle = null;
+ let heatmapEnabled = true;
+ let movementEnabled = true;
+ let autoFollowEnabled = true;
+ let smoothingEnabled = true;
+ let lastRenderedDetectionKey = null;
+
+ const MAX_HEAT_POINTS = 1200;
+ const MAX_TRAIL_POINTS = 1200;
+ const CONFIDENCE_WINDOW_POINTS = 8;
+ const OUTLIER_HARD_JUMP_METERS = 2000;
+ const OUTLIER_SOFT_JUMP_METERS = 450;
+ const OUTLIER_MAX_SPEED_MPS = 50;
+ const OVERLAY_STORAGE_KEYS = {
+ heatmap: 'btLocateHeatmapEnabled',
+ movement: 'btLocateMovementEnabled',
+ follow: 'btLocateFollowEnabled',
+ smoothing: 'btLocateSmoothingEnabled',
+ };
+
+ const HEAT_LAYER_OPTIONS = {
+ radius: 26,
+ blur: 20,
+ minOpacity: 0.25,
+ maxZoom: 19,
+ gradient: {
+ 0.15: '#2563eb',
+ 0.45: '#16a34a',
+ 0.75: '#f59e0b',
+ 1.0: '#ef4444',
+ },
+ };
+
+ function init() {
+ loadOverlayPreferences();
+ syncOverlayControls();
+
+ if (initialized) {
+ // Re-invalidate map on re-entry and ensure tiles are present
+ if (map) {
+ setTimeout(() => {
+ map.invalidateSize();
// Re-apply user's tile layer if tiles were lost
let hasTiles = false;
map.eachLayer(layer => {
@@ -46,11 +87,11 @@ const BtLocate = (function() {
}
// Init map
- const mapEl = document.getElementById('btLocateMap');
- if (mapEl && typeof L !== 'undefined') {
- map = L.map('btLocateMap', {
- center: [0, 0],
- zoom: 2,
+ const mapEl = document.getElementById('btLocateMap');
+ if (mapEl && typeof L !== 'undefined') {
+ map = L.map('btLocateMap', {
+ center: [0, 0],
+ zoom: 2,
zoomControl: true,
});
// Use tile provider from user settings
@@ -62,9 +103,12 @@ const BtLocate = (function() {
maxZoom: 19,
attribution: '© OSM © CARTO'
}).addTo(map);
- }
- setTimeout(() => map.invalidateSize(), 100);
- }
+ }
+ ensureHeatLayer();
+ syncMovementLayer();
+ syncHeatLayer();
+ setTimeout(() => map.invalidateSize(), 100);
+ }
// Init RSSI chart canvas
chartCanvas = document.getElementById('btLocateRssiChart');
@@ -80,23 +124,15 @@ const BtLocate = (function() {
fetch('/bt_locate/status')
.then(r => r.json())
.then(data => {
- if (data.active) {
- sessionStartedAt = data.started_at ? new Date(data.started_at).getTime() : Date.now();
- showActiveUI();
- updateScanStatus(data);
- if (!eventSource) connectSSE();
- // Restore trail from server
- fetch('/bt_locate/trail')
- .then(r => r.json())
- .then(trail => {
- if (trail.gps_trail) {
- trail.gps_trail.forEach(p => addMapMarker(p));
- }
- updateStats(data.detection_count, data.gps_trail_count);
- });
- }
- })
- .catch(() => {});
+ if (data.active) {
+ sessionStartedAt = data.started_at ? new Date(data.started_at).getTime() : Date.now();
+ showActiveUI();
+ updateScanStatus(data);
+ if (!eventSource) connectSSE();
+ restoreTrail();
+ }
+ })
+ .catch(() => {});
}
function start() {
@@ -135,17 +171,18 @@ const BtLocate = (function() {
})
.then(r => r.json())
.then(data => {
- if (data.status === 'started') {
- sessionStartedAt = data.session?.started_at ? new Date(data.session.started_at).getTime() : Date.now();
- showActiveUI();
- connectSSE();
- rssiHistory = [];
- gpsLocked = false;
- updateScanStatus(data.session);
- // Restore any existing trail (e.g. from a stop/start cycle)
- restoreTrail();
- }
- })
+ if (data.status === 'started') {
+ sessionStartedAt = data.session?.started_at ? new Date(data.session.started_at).getTime() : Date.now();
+ showActiveUI();
+ connectSSE();
+ rssiHistory = [];
+ gpsLocked = false;
+ lastRenderedDetectionKey = null;
+ updateScanStatus(data.session);
+ // Restore any existing trail (e.g. from a stop/start cycle)
+ restoreTrail();
+ }
+ })
.catch(err => console.error('[BtLocate] Start error:', err));
}
@@ -301,19 +338,19 @@ const BtLocate = (function() {
}
// If detection count increased, fetch new trail points
- if (data.detection_count > lastDetectionCount) {
- lastDetectionCount = data.detection_count;
- fetch('/bt_locate/trail')
- .then(r => r.json())
- .then(trail => {
- if (trail.trail && trail.trail.length > 0) {
- const latest = trail.trail[trail.trail.length - 1];
- handleDetection({ data: latest });
- }
- updateStats(data.detection_count, data.gps_trail_count);
- });
- }
- })
+ if (data.detection_count > lastDetectionCount) {
+ lastDetectionCount = data.detection_count;
+ fetch('/bt_locate/trail')
+ .then(r => r.json())
+ .then(trail => {
+ if (trail.trail && trail.trail.length > 0) {
+ const latest = trail.trail[trail.trail.length - 1];
+ handleDetection({ data: latest }, { skipStatsIncrement: true });
+ }
+ updateStats(data.detection_count, data.gps_trail_count);
+ });
+ }
+ })
.catch(() => {});
}
@@ -361,49 +398,77 @@ const BtLocate = (function() {
}
}
- function handleDetection(event) {
- const d = event.data;
- if (!d) return;
-
- // Update proximity UI
- const bandEl = document.getElementById('btLocateBand');
- const distEl = document.getElementById('btLocateDistance');
- const rssiEl = document.getElementById('btLocateRssi');
- const rssiEmaEl = document.getElementById('btLocateRssiEma');
-
- if (bandEl) {
- bandEl.textContent = d.proximity_band;
- bandEl.className = 'btl-hud-band ' + d.proximity_band.toLowerCase();
- }
- if (distEl) distEl.textContent = d.estimated_distance.toFixed(1);
- if (rssiEl) rssiEl.textContent = d.rssi;
- if (rssiEmaEl) rssiEmaEl.textContent = d.rssi_ema.toFixed(1);
-
- // RSSI sparkline
- rssiHistory.push(d.rssi);
- if (rssiHistory.length > MAX_RSSI_POINTS) rssiHistory.shift();
- drawRssiChart();
-
- // Map marker
- if (d.lat != null && d.lon != null) {
- addMapMarker(d);
- }
-
- // Update stats
- const detCountEl = document.getElementById('btLocateDetectionCount');
- const gpsCountEl = document.getElementById('btLocateGpsCount');
- if (detCountEl) {
- const cur = parseInt(detCountEl.textContent) || 0;
- detCountEl.textContent = cur + 1;
- }
- if (gpsCountEl && d.lat != null) {
- const cur = parseInt(gpsCountEl.textContent) || 0;
- gpsCountEl.textContent = cur + 1;
- }
-
- // Audio
- if (audioEnabled) playProximityTone(d.rssi);
- }
+ function handleDetection(event, options = {}) {
+ const d = event?.data || event;
+ if (!d) return;
+ const detectionKey = buildDetectionKey(d);
+ if (!options.allowDuplicate && detectionKey && detectionKey === lastRenderedDetectionKey) {
+ return;
+ }
+ if (detectionKey) {
+ lastRenderedDetectionKey = detectionKey;
+ }
+
+ updateDetectionHud(d);
+
+ // RSSI sparkline
+ if (typeof d.rssi === 'number' && isFinite(d.rssi)) {
+ rssiHistory.push(d.rssi);
+ if (rssiHistory.length > MAX_RSSI_POINTS) rssiHistory.shift();
+ drawRssiChart();
+ }
+
+ // Map marker
+ let mapPointAdded = false;
+ if (d.lat != null && d.lon != null) {
+ mapPointAdded = addMapMarker(d, { suppressFollow: options.suppressFollow === true });
+ }
+
+ // Update stats
+ if (!options.skipStatsIncrement) {
+ const detCountEl = document.getElementById('btLocateDetectionCount');
+ const gpsCountEl = document.getElementById('btLocateGpsCount');
+ if (detCountEl) {
+ const cur = parseInt(detCountEl.textContent) || 0;
+ detCountEl.textContent = cur + 1;
+ }
+ if (gpsCountEl && mapPointAdded) {
+ const cur = parseInt(gpsCountEl.textContent) || 0;
+ gpsCountEl.textContent = cur + 1;
+ }
+ }
+
+ // Audio
+ if (audioEnabled) playProximityTone(d.rssi);
+ }
+
+ function updateDetectionHud(d) {
+ const bandEl = document.getElementById('btLocateBand');
+ const distEl = document.getElementById('btLocateDistance');
+ const rssiEl = document.getElementById('btLocateRssi');
+ const rssiEmaEl = document.getElementById('btLocateRssiEma');
+
+ if (bandEl) {
+ bandEl.textContent = d.proximity_band || '---';
+ const bandClass = (d.proximity_band || '').toLowerCase();
+ bandEl.className = bandClass ? 'btl-hud-band ' + bandClass : 'btl-hud-band';
+ }
+ if (distEl) {
+ if (typeof d.estimated_distance === 'number' && isFinite(d.estimated_distance)) {
+ distEl.textContent = d.estimated_distance.toFixed(1);
+ } else {
+ distEl.textContent = '--';
+ }
+ }
+ if (rssiEl) rssiEl.textContent = d.rssi != null ? d.rssi : '--';
+ if (rssiEmaEl) {
+ if (typeof d.rssi_ema === 'number' && isFinite(d.rssi_ema)) {
+ rssiEmaEl.textContent = d.rssi_ema.toFixed(1);
+ } else {
+ rssiEmaEl.textContent = '--';
+ }
+ }
+ }
function updateStats(detections, gpsPoints) {
const detCountEl = document.getElementById('btLocateDetectionCount');
@@ -412,83 +477,741 @@ const BtLocate = (function() {
if (gpsCountEl) gpsCountEl.textContent = gpsPoints || 0;
}
- function addMapMarker(point) {
- if (!map || point.lat == null || point.lon == null) return;
-
- const band = (point.proximity_band || 'FAR').toLowerCase();
- const colors = { immediate: '#ef4444', near: '#f97316', far: '#eab308' };
- const sizes = { immediate: 8, near: 6, far: 5 };
- const color = colors[band] || '#eab308';
- const radius = sizes[band] || 5;
-
- const marker = L.circleMarker([point.lat, point.lon], {
- radius: radius,
- fillColor: color,
- color: '#fff',
- weight: 1,
- opacity: 0.9,
- fillOpacity: 0.8,
- }).addTo(map);
-
- marker.bindPopup(
- '
' +
- '' + point.proximity_band + '
' +
- 'RSSI: ' + point.rssi + ' dBm
' +
- 'Distance: ~' + point.estimated_distance.toFixed(1) + ' m
' +
- 'Time: ' + new Date(point.timestamp).toLocaleTimeString() +
- '
'
- );
-
- mapMarkers.push(marker);
-
- if (!gpsLocked) {
- gpsLocked = true;
- map.setView([point.lat, point.lon], map.getMaxZoom());
- } else {
- map.panTo([point.lat, point.lon]);
- }
-
- // Update trail line
- const latlngs = mapMarkers.map(m => m.getLatLng());
- if (trailLine) {
- trailLine.setLatLngs(latlngs);
- } else if (latlngs.length >= 2) {
- trailLine = L.polyline(latlngs, {
- color: 'rgba(0,255,136,0.5)',
- weight: 2,
- dashArray: '4 4',
- }).addTo(map);
- }
- }
-
- function restoreTrail() {
- fetch('/bt_locate/trail')
- .then(r => r.json())
- .then(trail => {
- if (trail.gps_trail && trail.gps_trail.length > 0) {
- clearMapMarkers();
- trail.gps_trail.forEach(p => addMapMarker(p));
- }
- if (trail.trail && trail.trail.length > 0) {
- // Restore RSSI history from trail
- rssiHistory = trail.trail.map(p => p.rssi).slice(-MAX_RSSI_POINTS);
- drawRssiChart();
- // Update HUD with latest detection
- const latest = trail.trail[trail.trail.length - 1];
- handleDetection({ data: latest });
- }
- })
- .catch(() => {});
- }
-
- function clearMapMarkers() {
- mapMarkers.forEach(m => map?.removeLayer(m));
- mapMarkers = [];
- if (trailLine) {
- map?.removeLayer(trailLine);
- trailLine = null;
- }
- }
+ function addMapMarker(point, options = {}) {
+ if (!map || point.lat == null || point.lon == null) return false;
+ const lat = Number(point.lat);
+ const lon = Number(point.lon);
+ if (!isFinite(lat) || !isFinite(lon)) return false;
+ if (!shouldAcceptMapPoint(point, lat, lon)) return false;
+
+ const trailPoint = normalizeTrailPoint(point, lat, lon);
+ const band = (trailPoint.proximity_band || 'FAR').toLowerCase();
+ const colors = { immediate: '#ef4444', near: '#f97316', far: '#eab308' };
+ const sizes = { immediate: 8, near: 6, far: 5 };
+ const color = colors[band] || '#eab308';
+ const radius = sizes[band] || 5;
+
+ const marker = L.circleMarker([lat, lon], {
+ radius: radius,
+ fillColor: color,
+ color: '#fff',
+ weight: 1,
+ opacity: 0.9,
+ fillOpacity: 0.8,
+ btLocateMeta: trailPoint,
+ }).addTo(map);
+
+ marker.bindPopup(
+ '' +
+ '' + (trailPoint.proximity_band || 'Unknown') + '
' +
+ 'RSSI: ' + (trailPoint.rssi != null ? trailPoint.rssi : '--') + ' dBm
' +
+ 'Distance: ~' + formatDistanceForPopup(trailPoint.estimated_distance) + ' m
' +
+ 'Time: ' + formatPointTimestamp(trailPoint.timestamp) +
+ '
'
+ );
+
+ trailPoints.push(trailPoint);
+ mapMarkers.push(marker);
+ heatPoints.push([lat, lon, rssiToHeatWeight(trailPoint.rssi)]);
+
+ while (trailPoints.length > MAX_TRAIL_POINTS) {
+ trailPoints.shift();
+ const oldMarker = mapMarkers.shift();
+ if (oldMarker && map) map.removeLayer(oldMarker);
+ }
+ if (heatPoints.length > MAX_HEAT_POINTS) {
+ heatPoints.splice(0, heatPoints.length - MAX_HEAT_POINTS);
+ }
+ syncHeatLayer();
+
+ if (autoFollowEnabled && !options.suppressFollow) {
+ if (!gpsLocked) {
+ gpsLocked = true;
+ map.setView([lat, lon], Math.max(map.getZoom(), 16));
+ } else {
+ map.panTo([lat, lon], { animate: true, duration: 0.35 });
+ }
+ } else {
+ gpsLocked = true;
+ }
+
+ syncMovementLayer();
+ syncStrongestMarker();
+ updateConfidenceLayer();
+ updateMovementStats();
+ return true;
+ }
+
+ function normalizeTrailPoint(point, lat, lon) {
+ const rssiVal = Number(point.rssi);
+ const rssiEmaVal = Number(point.rssi_ema);
+ const distVal = Number(point.estimated_distance);
+ return {
+ lat: lat,
+ lon: lon,
+ rssi: isFinite(rssiVal) ? rssiVal : null,
+ rssi_ema: isFinite(rssiEmaVal) ? rssiEmaVal : null,
+ estimated_distance: isFinite(distVal) ? distVal : null,
+ proximity_band: point.proximity_band || 'FAR',
+ timestamp: point.timestamp || null,
+ };
+ }
+
+ function shouldAcceptMapPoint(point, lat, lon) {
+ if (trailPoints.length === 0) return true;
+ const prev = trailPoints[trailPoints.length - 1];
+ if (!prev) return true;
+
+ const distanceMeters = map
+ ? map.distance([prev.lat, prev.lon], [lat, lon])
+ : L.latLng(prev.lat, prev.lon).distanceTo(L.latLng(lat, lon));
+
+ if (!isFinite(distanceMeters)) return true;
+ if (distanceMeters > OUTLIER_HARD_JUMP_METERS) return false;
+
+ const prevTs = getTimestampMs(prev.timestamp);
+ const currTs = getTimestampMs(point.timestamp);
+ if (prevTs != null && currTs != null && currTs > prevTs) {
+ const elapsedSec = (currTs - prevTs) / 1000;
+ if (elapsedSec > 0) {
+ const speedMps = distanceMeters / elapsedSec;
+ if (distanceMeters > OUTLIER_SOFT_JUMP_METERS && speedMps > OUTLIER_MAX_SPEED_MPS) {
+ return false;
+ }
+ }
+ } else if (distanceMeters > OUTLIER_SOFT_JUMP_METERS) {
+ return false;
+ }
+
+ return true;
+ }
+
+ function getTimestampMs(value) {
+ if (!value) return null;
+ const ts = new Date(value).getTime();
+ return isNaN(ts) ? null : ts;
+ }
+
+ function restoreTrail() {
+ fetch('/bt_locate/trail')
+ .then(r => r.json())
+ .then(trail => {
+ clearMapMarkers();
+
+ const gpsTrail = Array.isArray(trail.gps_trail) ? trail.gps_trail : [];
+ const allTrail = Array.isArray(trail.trail) ? trail.trail : [];
+
+ gpsTrail.forEach(p => addMapMarker(p, { suppressFollow: true }));
+
+ if (allTrail.length > 0) {
+ rssiHistory = allTrail.map(p => p.rssi).filter(v => typeof v === 'number' && isFinite(v)).slice(-MAX_RSSI_POINTS);
+ drawRssiChart();
+ const latest = allTrail[allTrail.length - 1];
+ updateDetectionHud(latest);
+ lastRenderedDetectionKey = buildDetectionKey(latest);
+ } else {
+ rssiHistory = [];
+ drawRssiChart();
+ }
+
+ updateStats(allTrail.length, gpsTrail.length);
+
+ if (trailPoints.length > 0 && map) {
+ const latestGps = trailPoints[trailPoints.length - 1];
+ gpsLocked = true;
+ const targetZoom = Math.max(map.getZoom(), 15);
+ map.setView([latestGps.lat, latestGps.lon], targetZoom);
+ }
+ syncMovementLayer();
+ syncStrongestMarker();
+ updateConfidenceLayer();
+ updateMovementStats();
+ })
+ .catch(() => {});
+ }
+
+ function clearMapMarkers() {
+ mapMarkers.forEach(m => map?.removeLayer(m));
+ mapMarkers = [];
+ trailPoints = [];
+ heatPoints = [];
+ if (trailLine) {
+ map?.removeLayer(trailLine);
+ trailLine = null;
+ }
+ if (movementStartMarker) {
+ map?.removeLayer(movementStartMarker);
+ movementStartMarker = null;
+ }
+ if (movementHeadMarker) {
+ map?.removeLayer(movementHeadMarker);
+ movementHeadMarker = null;
+ }
+ if (strongestMarker) {
+ map?.removeLayer(strongestMarker);
+ strongestMarker = null;
+ }
+ if (confidenceCircle) {
+ map?.removeLayer(confidenceCircle);
+ confidenceCircle = null;
+ }
+ if (heatLayer) {
+ heatLayer.setLatLngs([]);
+ }
+ updateStrongestInfo(null);
+ updateConfidenceInfo(null);
+ updateMovementStats();
+ }
+
+ function syncStrongestMarker() {
+ if (!map) return;
+ const strongest = getStrongestTrailPoint();
+ if (!strongest) {
+ if (strongestMarker) {
+ map.removeLayer(strongestMarker);
+ strongestMarker = null;
+ }
+ updateStrongestInfo(null);
+ return;
+ }
+
+ const latlng = [strongest.lat, strongest.lon];
+ if (!strongestMarker) {
+ strongestMarker = L.circleMarker(latlng, {
+ radius: 7,
+ fillColor: '#f59e0b',
+ color: '#ffffff',
+ weight: 2,
+ fillOpacity: 0.9,
+ }).addTo(map).bindTooltip('Best RSSI', { direction: 'top' });
+ } else {
+ strongestMarker.setLatLng(latlng);
+ if (!map.hasLayer(strongestMarker)) {
+ strongestMarker.addTo(map);
+ }
+ }
+
+ strongestMarker.bindPopup(
+ '' +
+ 'Strongest Signal
' +
+ 'RSSI: ' + strongest.rssi + ' dBm
' +
+ 'Time: ' + formatPointTimestamp(strongest.timestamp) +
+ '
'
+ );
+ updateStrongestInfo(strongest);
+ }
+
+ function getStrongestTrailPoint() {
+ let best = null;
+ for (const p of trailPoints) {
+ if (typeof p.rssi !== 'number' || !isFinite(p.rssi)) continue;
+ if (!best || p.rssi > best.rssi) {
+ best = p;
+ }
+ }
+ return best;
+ }
+
+ function updateStrongestInfo(strongest) {
+ const strongestEl = document.getElementById('btLocateBestSignal');
+ if (!strongestEl) return;
+ if (!strongest || typeof strongest.rssi !== 'number' || !isFinite(strongest.rssi)) {
+ strongestEl.textContent = 'Best: --';
+ return;
+ }
+ strongestEl.textContent = 'Best: ' + strongest.rssi + ' dBm';
+ }
+
+ function updateConfidenceLayer() {
+ if (!map) return;
+ const latest = trailPoints[trailPoints.length - 1];
+ const radius = computeConfidenceRadiusMeters();
+ if (!latest || radius == null) {
+ if (confidenceCircle) {
+ map.removeLayer(confidenceCircle);
+ confidenceCircle = null;
+ }
+ updateConfidenceInfo(null);
+ return;
+ }
+
+ if (!confidenceCircle) {
+ confidenceCircle = L.circle([latest.lat, latest.lon], {
+ radius: radius,
+ color: '#93c5fd',
+ weight: 1,
+ fillColor: '#60a5fa',
+ fillOpacity: 0.08,
+ }).addTo(map);
+ } else {
+ confidenceCircle.setLatLng([latest.lat, latest.lon]);
+ confidenceCircle.setRadius(radius);
+ if (!map.hasLayer(confidenceCircle)) {
+ confidenceCircle.addTo(map);
+ }
+ }
+ updateConfidenceInfo(radius);
+ }
+
+ function computeConfidenceRadiusMeters() {
+ if (trailPoints.length < 2) return null;
+ const sample = trailPoints.slice(-CONFIDENCE_WINDOW_POINTS);
+ const distances = sample.map(p => p.estimated_distance).filter(v => typeof v === 'number' && isFinite(v) && v > 0);
+ const rssis = sample.map(p => p.rssi).filter(v => typeof v === 'number' && isFinite(v));
+ if (distances.length < 2 && rssis.length < 2) return null;
+
+ const meanDistance = distances.length > 0 ? average(distances) : 20;
+ const stdDistance = distances.length > 1 ? standardDeviation(distances) : 0;
+ const stdRssi = rssis.length > 1 ? standardDeviation(rssis) : 0;
+ const confidence = (meanDistance * 0.35) + (stdDistance * 1.6) + (stdRssi * 0.9) + 3;
+ return Math.max(4, Math.min(150, confidence));
+ }
+
+ function updateConfidenceInfo(radiusMeters) {
+ const confidenceEl = document.getElementById('btLocateConfidenceInfo');
+ if (!confidenceEl) return;
+ if (radiusMeters == null || !isFinite(radiusMeters)) {
+ confidenceEl.textContent = 'Confidence: --';
+ return;
+ }
+ confidenceEl.textContent = 'Confidence: +/-' + Math.round(radiusMeters) + ' m';
+ }
+
+ function buildDetectionKey(detection) {
+ if (!detection) return '';
+ const timestamp = detection.timestamp || '';
+ const lat = detection.lat != null ? Number(detection.lat).toFixed(6) : '';
+ const lon = detection.lon != null ? Number(detection.lon).toFixed(6) : '';
+ const rssi = detection.rssi != null ? String(detection.rssi) : '';
+ return [timestamp, lat, lon, rssi].join('|');
+ }
+
+ function rssiToHeatWeight(rssi) {
+ const value = Number(rssi);
+ if (!isFinite(value)) return 0.2;
+ const min = -100;
+ const max = -35;
+ const clamped = Math.max(min, Math.min(max, value));
+ return 0.1 + ((clamped - min) / (max - min)) * 0.9;
+ }
+
+ function ensureHeatLayer() {
+ if (!map || typeof L === 'undefined' || typeof L.heatLayer !== 'function') return;
+ if (!heatLayer) {
+ heatLayer = L.heatLayer([], HEAT_LAYER_OPTIONS);
+ }
+ }
+
+ function syncHeatLayer() {
+ if (!map) return;
+ ensureHeatLayer();
+ if (!heatLayer) return;
+ heatLayer.setLatLngs(heatPoints);
+ if (heatmapEnabled) {
+ if (!map.hasLayer(heatLayer)) {
+ heatLayer.addTo(map);
+ }
+ } else if (map.hasLayer(heatLayer)) {
+ map.removeLayer(heatLayer);
+ }
+ }
+
+ function syncMovementLayer() {
+ if (!map) return;
+ const rawLatlngs = trailPoints.map(p => L.latLng(p.lat, p.lon));
+ const latlngs = smoothingEnabled ? smoothLatLngs(rawLatlngs) : rawLatlngs;
+
+ if (!movementEnabled || latlngs.length < 2) {
+ if (trailLine) {
+ map.removeLayer(trailLine);
+ trailLine = null;
+ }
+ } else if (!trailLine) {
+ trailLine = L.polyline(latlngs, {
+ color: '#00ff88',
+ weight: 3,
+ opacity: 0.65,
+ smoothFactor: smoothingEnabled ? 1.0 : 0.2,
+ }).addTo(map);
+ } else {
+ trailLine.setLatLngs(latlngs);
+ trailLine.options.smoothFactor = smoothingEnabled ? 1.0 : 0.2;
+ if (!map.hasLayer(trailLine)) {
+ trailLine.addTo(map);
+ }
+ }
+
+ if (!movementEnabled || latlngs.length === 0) {
+ if (movementStartMarker) {
+ map.removeLayer(movementStartMarker);
+ movementStartMarker = null;
+ }
+ if (movementHeadMarker) {
+ map.removeLayer(movementHeadMarker);
+ movementHeadMarker = null;
+ }
+ return;
+ }
+
+ const start = rawLatlngs[0];
+ const latest = rawLatlngs[rawLatlngs.length - 1];
+
+ if (!movementStartMarker) {
+ movementStartMarker = L.circleMarker(start, {
+ radius: 4,
+ fillColor: '#38bdf8',
+ color: '#ffffff',
+ weight: 1,
+ fillOpacity: 0.9,
+ }).addTo(map).bindTooltip('Start', { direction: 'top' });
+ } else {
+ movementStartMarker.setLatLng(start);
+ if (!map.hasLayer(movementStartMarker)) {
+ movementStartMarker.addTo(map);
+ }
+ }
+
+ if (!movementHeadMarker) {
+ movementHeadMarker = L.circleMarker(latest, {
+ radius: 6,
+ fillColor: '#22c55e',
+ color: '#ffffff',
+ weight: 1,
+ fillOpacity: 1,
+ }).addTo(map).bindTooltip('Latest', { direction: 'top' });
+ } else {
+ movementHeadMarker.setLatLng(latest);
+ if (!map.hasLayer(movementHeadMarker)) {
+ movementHeadMarker.addTo(map);
+ }
+ }
+ }
+
+ function updateMovementStats() {
+ const statsEl = document.getElementById('btLocateTrackStats');
+ if (!statsEl) return;
+
+ const points = trailPoints.map(p => L.latLng(p.lat, p.lon));
+ if (points.length < 2) {
+ statsEl.textContent = 'Track: 0 m | ' + points.length + ' pts';
+ return;
+ }
+
+ let totalMeters = 0;
+ for (let i = 1; i < points.length; i++) {
+ totalMeters += points[i - 1].distanceTo(points[i]);
+ }
+
+ let speedSuffix = '';
+ const firstMeta = trailPoints[0] || null;
+ const lastMeta = trailPoints[points.length - 1] || null;
+ if (firstMeta?.timestamp && lastMeta?.timestamp) {
+ const elapsedSec = (new Date(lastMeta.timestamp).getTime() - new Date(firstMeta.timestamp).getTime()) / 1000;
+ if (elapsedSec > 5 && isFinite(elapsedSec)) {
+ const avgKmh = (totalMeters / elapsedSec) * 3.6;
+ if (isFinite(avgKmh)) {
+ speedSuffix = ' | avg ' + avgKmh.toFixed(avgKmh < 10 ? 1 : 0) + ' km/h';
+ }
+ }
+ }
+
+ statsEl.textContent = 'Track: ' + humanDistance(totalMeters) + ' | ' + points.length + ' pts' + speedSuffix;
+ }
+
+ function smoothLatLngs(latlngs) {
+ if (!Array.isArray(latlngs) || latlngs.length < 3) return latlngs;
+ const smoothed = [];
+ for (let i = 0; i < latlngs.length; i++) {
+ const start = Math.max(0, i - 1);
+ const end = Math.min(latlngs.length - 1, i + 1);
+ let latSum = 0;
+ let lngSum = 0;
+ let count = 0;
+ for (let j = start; j <= end; j++) {
+ latSum += latlngs[j].lat;
+ lngSum += latlngs[j].lng;
+ count += 1;
+ }
+ smoothed.push(L.latLng(latSum / count, lngSum / count));
+ }
+ return smoothed;
+ }
+
+ function humanDistance(meters) {
+ if (!isFinite(meters) || meters <= 0) return '0 m';
+ if (meters >= 1000) {
+ return (meters / 1000).toFixed(meters >= 10000 ? 1 : 2) + ' km';
+ }
+ return Math.round(meters) + ' m';
+ }
+
+ function formatDistanceForPopup(value) {
+ const dist = Number(value);
+ if (!isFinite(dist)) return '--';
+ return dist.toFixed(1);
+ }
+
+ function formatPointTimestamp(value) {
+ if (!value) return '--';
+ const ts = new Date(value);
+ if (isNaN(ts.getTime())) return '--';
+ return ts.toLocaleTimeString();
+ }
+
+ function average(values) {
+ if (!Array.isArray(values) || values.length === 0) return 0;
+ return values.reduce((sum, val) => sum + val, 0) / values.length;
+ }
+
+ function standardDeviation(values) {
+ if (!Array.isArray(values) || values.length < 2) return 0;
+ const mean = average(values);
+ const variance = values.reduce((sum, val) => {
+ const delta = val - mean;
+ return sum + (delta * delta);
+ }, 0) / values.length;
+ return Math.sqrt(variance);
+ }
+
+ function loadOverlayPreferences() {
+ const heatmapPref = localStorage.getItem(OVERLAY_STORAGE_KEYS.heatmap);
+ const movementPref = localStorage.getItem(OVERLAY_STORAGE_KEYS.movement);
+ const followPref = localStorage.getItem(OVERLAY_STORAGE_KEYS.follow);
+ const smoothingPref = localStorage.getItem(OVERLAY_STORAGE_KEYS.smoothing);
+ if (heatmapPref !== null) heatmapEnabled = heatmapPref === 'true';
+ if (movementPref !== null) movementEnabled = movementPref === 'true';
+ if (followPref !== null) autoFollowEnabled = followPref === 'true';
+ if (smoothingPref !== null) smoothingEnabled = smoothingPref === 'true';
+ }
+
+ function syncOverlayControls() {
+ const heatmapCb = document.getElementById('btLocateHeatmapEnable');
+ const movementCb = document.getElementById('btLocateMovementEnable');
+ const followCb = document.getElementById('btLocateFollowEnable');
+ const smoothCb = document.getElementById('btLocateSmoothEnable');
+ const legend = document.getElementById('btLocateHeatLegend');
+ const heatAvailable = typeof L !== 'undefined' && typeof L.heatLayer === 'function';
+
+ if (heatmapCb) {
+ heatmapCb.checked = heatAvailable ? heatmapEnabled : false;
+ heatmapCb.disabled = !heatAvailable;
+ }
+ if (movementCb) movementCb.checked = movementEnabled;
+ if (followCb) followCb.checked = autoFollowEnabled;
+ if (smoothCb) smoothCb.checked = smoothingEnabled;
+ if (legend) legend.style.display = heatmapEnabled && heatAvailable ? '' : 'none';
+ }
+
+ function toggleHeatmap() {
+ const cb = document.getElementById('btLocateHeatmapEnable');
+ heatmapEnabled = cb ? cb.checked : !heatmapEnabled;
+ localStorage.setItem(OVERLAY_STORAGE_KEYS.heatmap, String(heatmapEnabled));
+ syncOverlayControls();
+ syncHeatLayer();
+ }
+
+ function toggleMovement() {
+ const cb = document.getElementById('btLocateMovementEnable');
+ movementEnabled = cb ? cb.checked : !movementEnabled;
+ localStorage.setItem(OVERLAY_STORAGE_KEYS.movement, String(movementEnabled));
+ syncOverlayControls();
+ syncMovementLayer();
+ updateMovementStats();
+ }
+
+ function toggleFollow() {
+ const cb = document.getElementById('btLocateFollowEnable');
+ autoFollowEnabled = cb ? cb.checked : !autoFollowEnabled;
+ localStorage.setItem(OVERLAY_STORAGE_KEYS.follow, String(autoFollowEnabled));
+ syncOverlayControls();
+ }
+
+ function toggleSmoothing() {
+ const cb = document.getElementById('btLocateSmoothEnable');
+ smoothingEnabled = cb ? cb.checked : !smoothingEnabled;
+ localStorage.setItem(OVERLAY_STORAGE_KEYS.smoothing, String(smoothingEnabled));
+ syncOverlayControls();
+ syncMovementLayer();
+ }
+
+ function exportTrail(format) {
+ const formatSel = document.getElementById('btLocateExportFormat');
+ const exportFormat = String(format || formatSel?.value || 'csv').toLowerCase();
+ fetch('/bt_locate/trail')
+ .then(r => r.json())
+ .then(data => {
+ const allTrail = Array.isArray(data.trail) ? data.trail : [];
+ if (allTrail.length === 0) {
+ notifyExport('No data', 'No trail data to export yet.');
+ return;
+ }
+
+ let payload = '';
+ let mime = 'text/plain;charset=utf-8';
+ let ext = exportFormat;
+
+ if (exportFormat === 'csv') {
+ payload = buildTrailCsv(allTrail);
+ mime = 'text/csv;charset=utf-8';
+ ext = 'csv';
+ } else if (exportFormat === 'gpx') {
+ payload = buildTrailGpx(allTrail);
+ mime = 'application/gpx+xml;charset=utf-8';
+ ext = 'gpx';
+ } else if (exportFormat === 'kml') {
+ payload = buildTrailKml(allTrail);
+ mime = 'application/vnd.google-earth.kml+xml;charset=utf-8';
+ ext = 'kml';
+ } else {
+ notifyExport('Export failed', 'Unsupported export format: ' + exportFormat);
+ return;
+ }
+
+ const downloaded = downloadTrailFile('bt-locate-' + buildExportStamp() + '.' + ext, payload, mime);
+ if (downloaded) {
+ notifyExport('Export ready', 'Downloaded BT Locate trail as ' + ext.toUpperCase());
+ }
+ })
+ .catch(err => {
+ console.error('[BtLocate] Export failed:', err);
+ notifyExport('Export failed', 'Could not export trail data.');
+ });
+ }
+
+ function buildTrailCsv(trail) {
+ const header = [
+ 'timestamp',
+ 'lat',
+ 'lon',
+ 'rssi',
+ 'rssi_ema',
+ 'estimated_distance',
+ 'proximity_band',
+ ];
+ const rows = trail.map(p => [
+ csvEscape(p.timestamp || ''),
+ csvEscape(p.lat),
+ csvEscape(p.lon),
+ csvEscape(p.rssi),
+ csvEscape(p.rssi_ema),
+ csvEscape(p.estimated_distance),
+ csvEscape(p.proximity_band || ''),
+ ].join(','));
+ return [header.join(','), ...rows].join('\n');
+ }
+
+ function buildTrailGpx(trail) {
+ const pts = trail.filter(p => p.lat != null && p.lon != null);
+ if (pts.length === 0) return '';
+ const trkPts = pts.map(p => {
+ const rssi = p.rssi != null ? '' + escapeXml(String(p.rssi)) + '' : '';
+ const isoTime = toIsoStringSafe(p.timestamp);
+ const time = isoTime ? '' : '';
+ return (
+ '' +
+ time +
+ rssi +
+ ''
+ );
+ }).join('');
+
+ return (
+ '' +
+ '' +
+ 'BT Locate Trail' +
+ trkPts +
+ '' +
+ ''
+ );
+ }
+
+ function buildTrailKml(trail) {
+ const pts = trail.filter(p => p.lat != null && p.lon != null);
+ if (pts.length === 0) return '';
+ const lineCoords = pts.map(p => Number(p.lon).toFixed(6) + ',' + Number(p.lat).toFixed(6) + ',0').join(' ');
+ const pointPlacemarks = pts.map((p, idx) => {
+ const label = 'Point ' + (idx + 1) + ' | RSSI ' + (p.rssi != null ? p.rssi : '--') + ' dBm';
+ const desc = 'Time: ' + (toIsoStringSafe(p.timestamp) || '--');
+ return (
+ '' +
+ '' + escapeXml(label) + '' +
+ '' + escapeXml(desc) + '' +
+ '' + Number(p.lon).toFixed(6) + ',' + Number(p.lat).toFixed(6) + ',0' +
+ ''
+ );
+ }).join('');
+
+ return (
+ '' +
+ '' +
+ 'BT Locate Trail' +
+ 'Trail1' + lineCoords + '' +
+ pointPlacemarks +
+ ''
+ );
+ }
+
+ function csvEscape(value) {
+ if (value == null) return '';
+ const text = String(value);
+ if (/[",\n]/.test(text)) {
+ return '"' + text.replace(/"/g, '""') + '"';
+ }
+ return text;
+ }
+
+ function escapeXml(value) {
+ if (value == null) return '';
+ return String(value)
+ .replace(/&/g, '&')
+ .replace(//g, '>')
+ .replace(/"/g, '"')
+ .replace(/'/g, ''');
+ }
+
+ function toIsoStringSafe(value) {
+ if (!value) return '';
+ const ts = new Date(value);
+ if (isNaN(ts.getTime())) return '';
+ return ts.toISOString();
+ }
+
+ function buildExportStamp() {
+ const now = new Date();
+ const y = now.getFullYear();
+ const m = String(now.getMonth() + 1).padStart(2, '0');
+ const d = String(now.getDate()).padStart(2, '0');
+ const hh = String(now.getHours()).padStart(2, '0');
+ const mm = String(now.getMinutes()).padStart(2, '0');
+ const ss = String(now.getSeconds()).padStart(2, '0');
+ return '' + y + m + d + '-' + hh + mm + ss;
+ }
+
+ function downloadTrailFile(filename, content, mimeType) {
+ if (!content) {
+ notifyExport('No data', 'No GPS points available for this export format.');
+ return false;
+ }
+ const blob = new Blob([content], { type: mimeType || 'text/plain;charset=utf-8' });
+ const url = URL.createObjectURL(blob);
+ const link = document.createElement('a');
+ link.href = url;
+ link.download = filename;
+ document.body.appendChild(link);
+ link.click();
+ document.body.removeChild(link);
+ URL.revokeObjectURL(url);
+ return true;
+ }
+
+ function notifyExport(title, message) {
+ if (typeof showNotification === 'function') {
+ showNotification(title, message);
+ } else {
+ console.log('[BtLocate] ' + title + ': ' + message);
+ }
+ }
function drawRssiChart() {
if (!chartCtx || !chartCanvas) return;
@@ -725,18 +1448,19 @@ const BtLocate = (function() {
if (picker) picker.style.display = 'none';
}
- function clearTrail() {
- fetch('/bt_locate/clear_trail', { method: 'POST' })
- .then(r => r.json())
- .then(() => {
- clearMapMarkers();
- rssiHistory = [];
- gpsLocked = false;
- drawRssiChart();
- updateStats(0, 0);
- })
- .catch(err => console.error('[BtLocate] Clear trail error:', err));
- }
+ function clearTrail() {
+ fetch('/bt_locate/clear_trail', { method: 'POST' })
+ .then(r => r.json())
+ .then(() => {
+ clearMapMarkers();
+ rssiHistory = [];
+ gpsLocked = false;
+ lastRenderedDetectionKey = null;
+ drawRssiChart();
+ updateStats(0, 0);
+ })
+ .catch(err => console.error('[BtLocate] Clear trail error:', err));
+ }
function invalidateMap() {
if (map) map.invalidateSize();
@@ -749,8 +1473,13 @@ const BtLocate = (function() {
handoff,
clearHandoff,
setEnvironment,
- toggleAudio,
- clearTrail,
+ toggleAudio,
+ toggleHeatmap,
+ toggleMovement,
+ toggleFollow,
+ toggleSmoothing,
+ exportTrail,
+ clearTrail,
handleDetection,
invalidateMap,
fetchPairedIrks,
diff --git a/templates/index.html b/templates/index.html
index ddda205..e3047c6 100644
--- a/templates/index.html
+++ b/templates/index.html
@@ -35,9 +35,11 @@
{% if offline_settings.assets_source == 'local' %}
+
{% else %}
+
{% endif %}
{% if offline_settings.assets_source == 'local' %}
@@ -65,7 +67,7 @@
-
+
@@ -2240,6 +2242,14 @@
Audio
+
+
+
+
@@ -2252,12 +2262,43 @@
GPS: --
·
Last: --
+ ·
+ Confidence: --
+ ·
+ Best: --
+
+
+
+
+
+
+
+
Signal Heat
+
+
+ Weak
+ Strong
+
+
+
Track: 0 m | 0 pts