Enhance BT Locate with smoothing, confidence, strongest signal, and export

This commit is contained in:
Smittix
2026-02-19 12:51:25 +00:00
parent 0346d154c4
commit 16fa2b4270
3 changed files with 1124 additions and 224 deletions
+130
View File
@@ -170,6 +170,23 @@
flex-shrink: 0; 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 { .btl-hud-audio-toggle {
display: flex; display: flex;
align-items: center; align-items: center;
@@ -256,6 +273,7 @@
.btl-map-container { .btl-map-container {
flex: 1; flex: 1;
min-height: 250px; min-height: 250px;
position: relative;
border-radius: 8px; border-radius: 8px;
overflow: hidden; overflow: hidden;
border: 1px solid rgba(255, 255, 255, 0.1); border: 1px solid rgba(255, 255, 255, 0.1);
@@ -267,6 +285,94 @@
background: #1a1a2e; 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 { .btl-rssi-chart-container {
height: 100px; height: 100px;
background: rgba(0, 0, 0, 0.3); background: rgba(0, 0, 0, 0.3);
@@ -426,5 +532,29 @@
flex-direction: row; flex-direction: row;
width: 100%; width: 100%;
justify-content: center; 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;
} }
} }
+802 -73
View File
@@ -8,6 +8,7 @@ const BtLocate = (function() {
let eventSource = null; let eventSource = null;
let map = null; let map = null;
let mapMarkers = []; let mapMarkers = [];
let trailPoints = [];
let trailLine = null; let trailLine = null;
let rssiHistory = []; let rssiHistory = [];
const MAX_RSSI_POINTS = 60; const MAX_RSSI_POINTS = 60;
@@ -24,8 +25,48 @@ const BtLocate = (function() {
let sessionStartedAt = null; let sessionStartedAt = null;
let lastDetectionCount = 0; let lastDetectionCount = 0;
let gpsLocked = false; 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() { function init() {
loadOverlayPreferences();
syncOverlayControls();
if (initialized) { if (initialized) {
// Re-invalidate map on re-entry and ensure tiles are present // Re-invalidate map on re-entry and ensure tiles are present
if (map) { if (map) {
@@ -63,6 +104,9 @@ const BtLocate = (function() {
attribution: '© OSM © CARTO' attribution: '© OSM © CARTO'
}).addTo(map); }).addTo(map);
} }
ensureHeatLayer();
syncMovementLayer();
syncHeatLayer();
setTimeout(() => map.invalidateSize(), 100); setTimeout(() => map.invalidateSize(), 100);
} }
@@ -85,15 +129,7 @@ const BtLocate = (function() {
showActiveUI(); showActiveUI();
updateScanStatus(data); updateScanStatus(data);
if (!eventSource) connectSSE(); if (!eventSource) connectSSE();
// Restore trail from server restoreTrail();
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(() => {}); .catch(() => {});
@@ -141,6 +177,7 @@ const BtLocate = (function() {
connectSSE(); connectSSE();
rssiHistory = []; rssiHistory = [];
gpsLocked = false; gpsLocked = false;
lastRenderedDetectionKey = null;
updateScanStatus(data.session); updateScanStatus(data.session);
// Restore any existing trail (e.g. from a stop/start cycle) // Restore any existing trail (e.g. from a stop/start cycle)
restoreTrail(); restoreTrail();
@@ -308,7 +345,7 @@ const BtLocate = (function() {
.then(trail => { .then(trail => {
if (trail.trail && trail.trail.length > 0) { if (trail.trail && trail.trail.length > 0) {
const latest = trail.trail[trail.trail.length - 1]; const latest = trail.trail[trail.trail.length - 1];
handleDetection({ data: latest }); handleDetection({ data: latest }, { skipStatsIncrement: true });
} }
updateStats(data.detection_count, data.gps_trail_count); updateStats(data.detection_count, data.gps_trail_count);
}); });
@@ -361,48 +398,76 @@ const BtLocate = (function() {
} }
} }
function handleDetection(event) { function handleDetection(event, options = {}) {
const d = event.data; const d = event?.data || event;
if (!d) return; if (!d) return;
const detectionKey = buildDetectionKey(d);
if (!options.allowDuplicate && detectionKey && detectionKey === lastRenderedDetectionKey) {
return;
}
if (detectionKey) {
lastRenderedDetectionKey = detectionKey;
}
// Update proximity UI 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 bandEl = document.getElementById('btLocateBand');
const distEl = document.getElementById('btLocateDistance'); const distEl = document.getElementById('btLocateDistance');
const rssiEl = document.getElementById('btLocateRssi'); const rssiEl = document.getElementById('btLocateRssi');
const rssiEmaEl = document.getElementById('btLocateRssiEma'); const rssiEmaEl = document.getElementById('btLocateRssiEma');
if (bandEl) { if (bandEl) {
bandEl.textContent = d.proximity_band; bandEl.textContent = d.proximity_band || '---';
bandEl.className = 'btl-hud-band ' + d.proximity_band.toLowerCase(); const bandClass = (d.proximity_band || '').toLowerCase();
bandEl.className = bandClass ? 'btl-hud-band ' + bandClass : 'btl-hud-band';
} }
if (distEl) distEl.textContent = d.estimated_distance.toFixed(1); if (distEl) {
if (rssiEl) rssiEl.textContent = d.rssi; if (typeof d.estimated_distance === 'number' && isFinite(d.estimated_distance)) {
if (rssiEmaEl) rssiEmaEl.textContent = d.rssi_ema.toFixed(1); distEl.textContent = d.estimated_distance.toFixed(1);
} else {
// RSSI sparkline distEl.textContent = '--';
rssiHistory.push(d.rssi); }
if (rssiHistory.length > MAX_RSSI_POINTS) rssiHistory.shift();
drawRssiChart();
// Map marker
if (d.lat != null && d.lon != null) {
addMapMarker(d);
} }
if (rssiEl) rssiEl.textContent = d.rssi != null ? d.rssi : '--';
// Update stats if (rssiEmaEl) {
const detCountEl = document.getElementById('btLocateDetectionCount'); if (typeof d.rssi_ema === 'number' && isFinite(d.rssi_ema)) {
const gpsCountEl = document.getElementById('btLocateGpsCount'); rssiEmaEl.textContent = d.rssi_ema.toFixed(1);
if (detCountEl) { } else {
const cur = parseInt(detCountEl.textContent) || 0; rssiEmaEl.textContent = '--';
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 updateStats(detections, gpsPoints) { function updateStats(detections, gpsPoints) {
@@ -412,71 +477,155 @@ const BtLocate = (function() {
if (gpsCountEl) gpsCountEl.textContent = gpsPoints || 0; if (gpsCountEl) gpsCountEl.textContent = gpsPoints || 0;
} }
function addMapMarker(point) { function addMapMarker(point, options = {}) {
if (!map || point.lat == null || point.lon == null) return; 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 band = (point.proximity_band || 'FAR').toLowerCase(); const trailPoint = normalizeTrailPoint(point, lat, lon);
const band = (trailPoint.proximity_band || 'FAR').toLowerCase();
const colors = { immediate: '#ef4444', near: '#f97316', far: '#eab308' }; const colors = { immediate: '#ef4444', near: '#f97316', far: '#eab308' };
const sizes = { immediate: 8, near: 6, far: 5 }; const sizes = { immediate: 8, near: 6, far: 5 };
const color = colors[band] || '#eab308'; const color = colors[band] || '#eab308';
const radius = sizes[band] || 5; const radius = sizes[band] || 5;
const marker = L.circleMarker([point.lat, point.lon], { const marker = L.circleMarker([lat, lon], {
radius: radius, radius: radius,
fillColor: color, fillColor: color,
color: '#fff', color: '#fff',
weight: 1, weight: 1,
opacity: 0.9, opacity: 0.9,
fillOpacity: 0.8, fillOpacity: 0.8,
btLocateMeta: trailPoint,
}).addTo(map); }).addTo(map);
marker.bindPopup( marker.bindPopup(
'<div style="font-family:monospace;font-size:11px;">' + '<div style="font-family:monospace;font-size:11px;">' +
'<b>' + point.proximity_band + '</b><br>' + '<b>' + (trailPoint.proximity_band || 'Unknown') + '</b><br>' +
'RSSI: ' + point.rssi + ' dBm<br>' + 'RSSI: ' + (trailPoint.rssi != null ? trailPoint.rssi : '--') + ' dBm<br>' +
'Distance: ~' + point.estimated_distance.toFixed(1) + ' m<br>' + 'Distance: ~' + formatDistanceForPopup(trailPoint.estimated_distance) + ' m<br>' +
'Time: ' + new Date(point.timestamp).toLocaleTimeString() + 'Time: ' + formatPointTimestamp(trailPoint.timestamp) +
'</div>' '</div>'
); );
trailPoints.push(trailPoint);
mapMarkers.push(marker); mapMarkers.push(marker);
heatPoints.push([lat, lon, rssiToHeatWeight(trailPoint.rssi)]);
if (!gpsLocked) { while (trailPoints.length > MAX_TRAIL_POINTS) {
gpsLocked = true; trailPoints.shift();
map.setView([point.lat, point.lon], map.getMaxZoom()); 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 { } else {
map.panTo([point.lat, point.lon]); gpsLocked = true;
} }
// Update trail line syncMovementLayer();
const latlngs = mapMarkers.map(m => m.getLatLng()); syncStrongestMarker();
if (trailLine) { updateConfidenceLayer();
trailLine.setLatLngs(latlngs); updateMovementStats();
} else if (latlngs.length >= 2) { return true;
trailLine = L.polyline(latlngs, { }
color: 'rgba(0,255,136,0.5)',
weight: 2, function normalizeTrailPoint(point, lat, lon) {
dashArray: '4 4', const rssiVal = Number(point.rssi);
}).addTo(map); 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() { function restoreTrail() {
fetch('/bt_locate/trail') fetch('/bt_locate/trail')
.then(r => r.json()) .then(r => r.json())
.then(trail => { .then(trail => {
if (trail.gps_trail && trail.gps_trail.length > 0) { clearMapMarkers();
clearMapMarkers();
trail.gps_trail.forEach(p => addMapMarker(p)); const gpsTrail = Array.isArray(trail.gps_trail) ? trail.gps_trail : [];
} const allTrail = Array.isArray(trail.trail) ? trail.trail : [];
if (trail.trail && trail.trail.length > 0) {
// Restore RSSI history from trail gpsTrail.forEach(p => addMapMarker(p, { suppressFollow: true }));
rssiHistory = trail.trail.map(p => p.rssi).slice(-MAX_RSSI_POINTS);
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(); drawRssiChart();
// Update HUD with latest detection
const latest = trail.trail[trail.trail.length - 1];
handleDetection({ data: latest });
} }
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(() => {}); .catch(() => {});
} }
@@ -484,10 +633,584 @@ const BtLocate = (function() {
function clearMapMarkers() { function clearMapMarkers() {
mapMarkers.forEach(m => map?.removeLayer(m)); mapMarkers.forEach(m => map?.removeLayer(m));
mapMarkers = []; mapMarkers = [];
trailPoints = [];
heatPoints = [];
if (trailLine) { if (trailLine) {
map?.removeLayer(trailLine); map?.removeLayer(trailLine);
trailLine = null; 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(
'<div style="font-family:monospace;font-size:11px;">' +
'<b>Strongest Signal</b><br>' +
'RSSI: ' + strongest.rssi + ' dBm<br>' +
'Time: ' + formatPointTimestamp(strongest.timestamp) +
'</div>'
);
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 ? '<extensions><rssi>' + escapeXml(String(p.rssi)) + '</rssi></extensions>' : '';
const isoTime = toIsoStringSafe(p.timestamp);
const time = isoTime ? '<time>' + escapeXml(isoTime) + '</time>' : '';
return (
'<trkpt lat="' + escapeXml(String(Number(p.lat).toFixed(6))) + '" lon="' + escapeXml(String(Number(p.lon).toFixed(6))) + '">' +
time +
rssi +
'</trkpt>'
);
}).join('');
return (
'<?xml version="1.0" encoding="UTF-8"?>' +
'<gpx version="1.1" creator="iNTERCEPT BT Locate" xmlns="http://www.topografix.com/GPX/1/1">' +
'<trk><name>BT Locate Trail</name><trkseg>' +
trkPts +
'</trkseg></trk>' +
'</gpx>'
);
}
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 (
'<Placemark>' +
'<name>' + escapeXml(label) + '</name>' +
'<description>' + escapeXml(desc) + '</description>' +
'<Point><coordinates>' + Number(p.lon).toFixed(6) + ',' + Number(p.lat).toFixed(6) + ',0</coordinates></Point>' +
'</Placemark>'
);
}).join('');
return (
'<?xml version="1.0" encoding="UTF-8"?>' +
'<kml xmlns="http://www.opengis.net/kml/2.2"><Document>' +
'<name>BT Locate Trail</name>' +
'<Placemark><name>Trail</name><LineString><tessellate>1</tessellate><coordinates>' + lineCoords + '</coordinates></LineString></Placemark>' +
pointPlacemarks +
'</Document></kml>'
);
}
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, '&amp;')
.replace(/</g, '&lt;')
.replace(/>/g, '&gt;')
.replace(/"/g, '&quot;')
.replace(/'/g, '&apos;');
}
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() { function drawRssiChart() {
@@ -732,6 +1455,7 @@ const BtLocate = (function() {
clearMapMarkers(); clearMapMarkers();
rssiHistory = []; rssiHistory = [];
gpsLocked = false; gpsLocked = false;
lastRenderedDetectionKey = null;
drawRssiChart(); drawRssiChart();
updateStats(0, 0); updateStats(0, 0);
}) })
@@ -750,6 +1474,11 @@ const BtLocate = (function() {
clearHandoff, clearHandoff,
setEnvironment, setEnvironment,
toggleAudio, toggleAudio,
toggleHeatmap,
toggleMovement,
toggleFollow,
toggleSmoothing,
exportTrail,
clearTrail, clearTrail,
handleDetection, handleDetection,
invalidateMap, invalidateMap,
+43 -2
View File
@@ -35,9 +35,11 @@
{% if offline_settings.assets_source == 'local' %} {% if offline_settings.assets_source == 'local' %}
<link rel="stylesheet" href="{{ url_for('static', filename='vendor/leaflet/leaflet.css') }}"> <link rel="stylesheet" href="{{ url_for('static', filename='vendor/leaflet/leaflet.css') }}">
<script src="{{ url_for('static', filename='vendor/leaflet/leaflet.js') }}"></script> <script src="{{ url_for('static', filename='vendor/leaflet/leaflet.js') }}"></script>
<script src="{{ url_for('static', filename='vendor/leaflet-heat/leaflet-heat.js') }}"></script>
{% else %} {% else %}
<link rel="stylesheet" href="https://unpkg.com/leaflet@1.9.4/dist/leaflet.css" crossorigin="" /> <link rel="stylesheet" href="https://unpkg.com/leaflet@1.9.4/dist/leaflet.css" crossorigin="" />
<script src="https://unpkg.com/leaflet@1.9.4/dist/leaflet.js" crossorigin=""></script> <script src="https://unpkg.com/leaflet@1.9.4/dist/leaflet.js" crossorigin=""></script>
<script src="https://cdn.jsdelivr.net/npm/leaflet.heat@0.2.0/dist/leaflet-heat.js"></script>
{% endif %} {% endif %}
<!-- Chart.js for signal strength graphs - Conditional CDN/Local loading --> <!-- Chart.js for signal strength graphs - Conditional CDN/Local loading -->
{% if offline_settings.assets_source == 'local' %} {% if offline_settings.assets_source == 'local' %}
@@ -65,7 +67,7 @@
<link rel="stylesheet" href="{{ url_for('static', filename='css/modes/sstv-general.css') }}"> <link rel="stylesheet" href="{{ url_for('static', filename='css/modes/sstv-general.css') }}">
<link rel="stylesheet" href="{{ url_for('static', filename='css/modes/gps.css') }}"> <link rel="stylesheet" href="{{ url_for('static', filename='css/modes/gps.css') }}">
<link rel="stylesheet" href="{{ url_for('static', filename='css/modes/subghz.css') }}?v={{ version }}&r=subghz_layout9"> <link rel="stylesheet" href="{{ url_for('static', filename='css/modes/subghz.css') }}?v={{ version }}&r=subghz_layout9">
<link rel="stylesheet" href="{{ url_for('static', filename='css/modes/bt_locate.css') }}?v={{ version }}&r=btlocate2"> <link rel="stylesheet" href="{{ url_for('static', filename='css/modes/bt_locate.css') }}?v={{ version }}&r=btlocate4">
<link rel="stylesheet" href="{{ url_for('static', filename='css/modes/space-weather.css') }}"> <link rel="stylesheet" href="{{ url_for('static', filename='css/modes/space-weather.css') }}">
<link rel="stylesheet" href="{{ url_for('static', filename='css/settings.css') }}"> <link rel="stylesheet" href="{{ url_for('static', filename='css/settings.css') }}">
<link rel="stylesheet" href="{{ url_for('static', filename='css/components/function-strip.css') }}"> <link rel="stylesheet" href="{{ url_for('static', filename='css/components/function-strip.css') }}">
@@ -2240,6 +2242,14 @@
<input type="checkbox" id="btLocateAudioEnable" onchange="BtLocate.toggleAudio()"> <input type="checkbox" id="btLocateAudioEnable" onchange="BtLocate.toggleAudio()">
<span>Audio</span> <span>Audio</span>
</label> </label>
<div class="btl-hud-export-row">
<select id="btLocateExportFormat" class="btl-hud-export-format">
<option value="csv">CSV</option>
<option value="gpx">GPX</option>
<option value="kml">KML</option>
</select>
<button class="btl-hud-clear-btn" onclick="BtLocate.exportTrail()">Export</button>
</div>
<button class="btl-hud-clear-btn" onclick="BtLocate.clearTrail()">Clear Trail</button> <button class="btl-hud-clear-btn" onclick="BtLocate.clearTrail()">Clear Trail</button>
</div> </div>
</div> </div>
@@ -2252,12 +2262,43 @@
<span class="btl-hud-info-item" id="btLocateGpsStatus">GPS: --</span> <span class="btl-hud-info-item" id="btLocateGpsStatus">GPS: --</span>
<span class="btl-hud-info-sep">&middot;</span> <span class="btl-hud-info-sep">&middot;</span>
<span class="btl-hud-info-item" id="btLocateLastSeen">Last: --</span> <span class="btl-hud-info-item" id="btLocateLastSeen">Last: --</span>
<span class="btl-hud-info-sep">&middot;</span>
<span class="btl-hud-info-item" id="btLocateConfidenceInfo">Confidence: --</span>
<span class="btl-hud-info-sep">&middot;</span>
<span class="btl-hud-info-item" id="btLocateBestSignal">Best: --</span>
</div> </div>
<div id="btLocateDiag" class="btl-hud-diag"></div> <div id="btLocateDiag" class="btl-hud-diag"></div>
</div> </div>
</div> </div>
<div class="btl-map-container"> <div class="btl-map-container">
<div id="btLocateMap"></div> <div id="btLocateMap"></div>
<div class="btl-map-overlay-controls">
<label class="btl-map-overlay-toggle">
<input type="checkbox" id="btLocateHeatmapEnable" onchange="BtLocate.toggleHeatmap()">
<span>Heatmap</span>
</label>
<label class="btl-map-overlay-toggle">
<input type="checkbox" id="btLocateMovementEnable" onchange="BtLocate.toggleMovement()">
<span>Movement</span>
</label>
<label class="btl-map-overlay-toggle">
<input type="checkbox" id="btLocateFollowEnable" onchange="BtLocate.toggleFollow()">
<span>Auto follow</span>
</label>
<label class="btl-map-overlay-toggle">
<input type="checkbox" id="btLocateSmoothEnable" onchange="BtLocate.toggleSmoothing()">
<span>Smooth path</span>
</label>
</div>
<div class="btl-map-heat-legend" id="btLocateHeatLegend">
<span class="btl-map-heat-label">Signal Heat</span>
<div class="btl-map-heat-bar"></div>
<div class="btl-map-heat-scale">
<span>Weak</span>
<span>Strong</span>
</div>
</div>
<div class="btl-map-track-stats" id="btLocateTrackStats">Track: 0 m | 0 pts</div>
</div> </div>
<div class="btl-rssi-chart-container"> <div class="btl-rssi-chart-container">
<span class="btl-chart-label">RSSI History</span> <span class="btl-chart-label">RSSI History</span>
@@ -3181,7 +3222,7 @@
<script src="{{ url_for('static', filename='js/modes/dmr.js') }}"></script> <script src="{{ url_for('static', filename='js/modes/dmr.js') }}"></script>
<script src="{{ url_for('static', filename='js/modes/websdr.js') }}"></script> <script src="{{ url_for('static', filename='js/modes/websdr.js') }}"></script>
<script src="{{ url_for('static', filename='js/modes/subghz.js') }}?v={{ version }}&r=subghz_layout9"></script> <script src="{{ url_for('static', filename='js/modes/subghz.js') }}?v={{ version }}&r=subghz_layout9"></script>
<script src="{{ url_for('static', filename='js/modes/bt_locate.js') }}?v={{ version }}&r=btlocate2"></script> <script src="{{ url_for('static', filename='js/modes/bt_locate.js') }}?v={{ version }}&r=btlocate4"></script>
<script src="{{ url_for('static', filename='js/modes/analytics.js') }}"></script> <script src="{{ url_for('static', filename='js/modes/analytics.js') }}"></script>
<script src="{{ url_for('static', filename='js/modes/space-weather.js') }}"></script> <script src="{{ url_for('static', filename='js/modes/space-weather.js') }}"></script>