d84aded40b
- Add Leaflet.heat plugin (leaflet-heat.js) - Create renderHeatmap() function with confidence-based intensity - Toggle between markers and heatmap view - Support frequency filtering in heatmap mode - Custom gradient: blue -> cyan -> lime -> yellow -> red - Configurable radius (25px), blur (15px), and maxZoom (17) - Console logging for debugging heatmap rendering - Intensity based on device match confidence (0.5 default)
277 lines
8.1 KiB
JavaScript
277 lines
8.1 KiB
JavaScript
// GigLez - Map Visualization
|
|
|
|
let map;
|
|
let markerLayer;
|
|
let markerClusterGroup;
|
|
let heatmapLayer;
|
|
let capturesData = [];
|
|
let heatmapEnabled = false;
|
|
|
|
// Frequency color mapping
|
|
const FREQUENCY_COLORS = {
|
|
315: '#10b981', // Green
|
|
433: '#3b82f6', // Blue
|
|
868: '#f59e0b', // Orange
|
|
915: '#ef4444', // Red
|
|
default: '#6b7280' // Gray
|
|
};
|
|
|
|
// Initialize map
|
|
document.addEventListener('DOMContentLoaded', () => {
|
|
initMap();
|
|
loadCaptures();
|
|
setupMapControls();
|
|
});
|
|
|
|
function initMap() {
|
|
// Create map centered on US
|
|
map = L.map('map').setView([39.8283, -98.5795], 4);
|
|
|
|
// Add OpenStreetMap tiles
|
|
L.tileLayer('https://{s}.tile.openstreetmap.org/{z}/{x}/{y}.png', {
|
|
attribution: '© <a href="https://www.openstreetmap.org/copyright">OpenStreetMap</a> contributors',
|
|
maxZoom: 19
|
|
}).addTo(map);
|
|
|
|
// Create marker cluster group
|
|
markerClusterGroup = L.markerClusterGroup({
|
|
maxClusterRadius: 50,
|
|
spiderfyOnMaxZoom: true,
|
|
showCoverageOnHover: false,
|
|
zoomToBoundsOnClick: true
|
|
});
|
|
|
|
// Create regular marker layer
|
|
markerLayer = L.layerGroup();
|
|
|
|
// Add cluster group by default
|
|
map.addLayer(markerClusterGroup);
|
|
|
|
// Export map to window for access from other modules
|
|
window.map = map;
|
|
}
|
|
|
|
function setupMapControls() {
|
|
// Cluster toggle
|
|
document.getElementById('cluster-toggle').addEventListener('change', (e) => {
|
|
if (e.target.checked) {
|
|
map.removeLayer(markerLayer);
|
|
map.addLayer(markerClusterGroup);
|
|
} else {
|
|
map.removeLayer(markerClusterGroup);
|
|
map.addLayer(markerLayer);
|
|
}
|
|
renderMarkers();
|
|
});
|
|
|
|
// Frequency filter
|
|
document.getElementById('frequency-filter').addEventListener('change', () => {
|
|
if (heatmapEnabled) {
|
|
renderHeatmap();
|
|
} else {
|
|
renderMarkers();
|
|
}
|
|
});
|
|
|
|
// Heatmap toggle
|
|
document.getElementById('heatmap-toggle').addEventListener('change', (e) => {
|
|
heatmapEnabled = e.target.checked;
|
|
if (heatmapEnabled) {
|
|
// Hide markers, show heatmap
|
|
map.removeLayer(markerClusterGroup);
|
|
map.removeLayer(markerLayer);
|
|
renderHeatmap();
|
|
} else {
|
|
// Hide heatmap, show markers
|
|
if (heatmapLayer) {
|
|
map.removeLayer(heatmapLayer);
|
|
}
|
|
// Re-add appropriate marker layer based on cluster toggle
|
|
const clusterEnabled = document.getElementById('cluster-toggle').checked;
|
|
if (clusterEnabled) {
|
|
map.addLayer(markerClusterGroup);
|
|
} else {
|
|
map.addLayer(markerLayer);
|
|
}
|
|
}
|
|
});
|
|
}
|
|
|
|
async function loadCaptures() {
|
|
try {
|
|
const response = await fetch('/api/v1/query/captures?limit=1000');
|
|
|
|
if (!response.ok) {
|
|
console.error('Failed to load captures');
|
|
return;
|
|
}
|
|
|
|
const data = await response.json();
|
|
capturesData = data.captures || [];
|
|
|
|
renderMarkers();
|
|
updateStats();
|
|
|
|
} catch (error) {
|
|
console.error('Error loading captures:', error);
|
|
}
|
|
}
|
|
|
|
function renderMarkers() {
|
|
// Clear existing markers
|
|
markerClusterGroup.clearLayers();
|
|
markerLayer.clearLayers();
|
|
|
|
// Get frequency filter
|
|
const frequencyFilter = document.getElementById('frequency-filter').value;
|
|
|
|
// Filter captures
|
|
let filtered = capturesData;
|
|
|
|
if (frequencyFilter) {
|
|
const targetFreq = parseInt(frequencyFilter) * 1e6;
|
|
filtered = capturesData.filter(c => {
|
|
const freq = c.frequency / 1e6;
|
|
return Math.abs(freq - parseInt(frequencyFilter)) < 50;
|
|
});
|
|
}
|
|
|
|
// Create markers
|
|
filtered.forEach(capture => {
|
|
const marker = createMarker(capture);
|
|
|
|
// Add to both layers (only one will be visible)
|
|
markerClusterGroup.addLayer(marker);
|
|
markerLayer.addLayer(marker);
|
|
});
|
|
|
|
// Update count
|
|
document.getElementById('total-captures').textContent = filtered.length;
|
|
}
|
|
|
|
function createMarker(capture) {
|
|
// Determine color based on frequency
|
|
const freqMHz = Math.round(capture.frequency / 1e6);
|
|
let color = FREQUENCY_COLORS.default;
|
|
|
|
for (const [freq, col] of Object.entries(FREQUENCY_COLORS)) {
|
|
if (freq === 'default') continue;
|
|
if (Math.abs(freqMHz - parseInt(freq)) < 50) {
|
|
color = col;
|
|
break;
|
|
}
|
|
}
|
|
|
|
// Create custom icon
|
|
const icon = L.divIcon({
|
|
className: 'custom-marker',
|
|
html: `<div style="background-color: ${color}; width: 12px; height: 12px; border-radius: 50%; border: 2px solid white; box-shadow: 0 0 4px rgba(0,0,0,0.3);"></div>`,
|
|
iconSize: [12, 12],
|
|
iconAnchor: [6, 6]
|
|
});
|
|
|
|
// Create marker
|
|
const marker = L.marker([capture.latitude, capture.longitude], { icon });
|
|
|
|
// Create popup
|
|
const popupContent = createPopupContent(capture);
|
|
marker.bindPopup(popupContent);
|
|
|
|
return marker;
|
|
}
|
|
|
|
function createPopupContent(capture) {
|
|
const freqMHz = (capture.frequency / 1e6).toFixed(2);
|
|
const date = new Date(capture.captured_at).toLocaleDateString();
|
|
|
|
let deviceInfo = 'Unknown Device';
|
|
if (capture.device_name) {
|
|
deviceInfo = `<strong>${capture.device_name}</strong>`;
|
|
if (capture.match_confidence) {
|
|
deviceInfo += ` (${(capture.match_confidence * 100).toFixed(0)}% confidence)`;
|
|
}
|
|
}
|
|
|
|
return `
|
|
<div class="marker-popup">
|
|
<h4>${deviceInfo}</h4>
|
|
<p><strong>Frequency:</strong> ${freqMHz} MHz</p>
|
|
<p><strong>Protocol:</strong> ${capture.protocol || 'RAW'}</p>
|
|
<p><strong>Captured:</strong> ${date}</p>
|
|
<p><strong>Location:</strong> ${capture.latitude.toFixed(6)}, ${capture.longitude.toFixed(6)}</p>
|
|
${capture.accuracy ? `<p><strong>Accuracy:</strong> ±${capture.accuracy.toFixed(1)}m</p>` : ''}
|
|
<button onclick="viewCaptureDetails(${capture.id})" class="btn btn-primary" style="margin-top: 0.5rem;">
|
|
View Details
|
|
</button>
|
|
</div>
|
|
`;
|
|
}
|
|
|
|
function updateStats() {
|
|
// Total captures
|
|
document.getElementById('total-captures').textContent = capturesData.length;
|
|
|
|
// Unique devices
|
|
const uniqueDevices = new Set(
|
|
capturesData
|
|
.filter(c => c.device_name)
|
|
.map(c => c.device_name)
|
|
).size;
|
|
document.getElementById('unique-devices').textContent = uniqueDevices;
|
|
}
|
|
|
|
function renderHeatmap() {
|
|
// Remove existing heatmap layer if it exists
|
|
if (heatmapLayer) {
|
|
map.removeLayer(heatmapLayer);
|
|
}
|
|
|
|
// Get frequency filter
|
|
const frequencyFilter = document.getElementById('frequency-filter').value;
|
|
|
|
// Filter captures
|
|
let filtered = capturesData;
|
|
|
|
if (frequencyFilter) {
|
|
const targetFreq = parseInt(frequencyFilter) * 1e6;
|
|
filtered = capturesData.filter(c => {
|
|
const freq = c.frequency / 1e6;
|
|
return Math.abs(freq - parseInt(frequencyFilter)) < 50;
|
|
});
|
|
}
|
|
|
|
// Convert captures to heatmap data format
|
|
// Format: [[lat, lng, intensity], ...]
|
|
const heatmapData = filtered.map(capture => {
|
|
// Use match confidence as intensity (default to 0.5 if no match)
|
|
const intensity = capture.match_confidence || 0.5;
|
|
return [capture.latitude, capture.longitude, intensity];
|
|
});
|
|
|
|
console.log(`Rendering heatmap with ${heatmapData.length} points`);
|
|
|
|
// Create heatmap layer
|
|
heatmapLayer = L.heatLayer(heatmapData, {
|
|
radius: 25, // Radius of each point
|
|
blur: 15, // Amount of blur
|
|
maxZoom: 17, // Zoom level where max intensity is reached
|
|
max: 1.0, // Maximum point intensity
|
|
gradient: { // Custom color gradient
|
|
0.0: 'blue',
|
|
0.3: 'cyan',
|
|
0.5: 'lime',
|
|
0.7: 'yellow',
|
|
1.0: 'red'
|
|
}
|
|
});
|
|
|
|
// Add to map
|
|
map.addLayer(heatmapLayer);
|
|
|
|
// Update count
|
|
document.getElementById('total-captures').textContent = filtered.length;
|
|
}
|
|
|
|
// Export for external use
|
|
window.reloadMapData = loadCaptures;
|