Phase 3 Complete: Web Interface MVP
Major Achievements: - ✅ Full web interface (1,520+ lines of frontend code) - ✅ Interactive Leaflet.js map with marker clustering - ✅ Drag-and-drop upload system with GPS input - ✅ Search & filter UI with multi-criteria - ✅ Statistics dashboard with Chart.js - ✅ Responsive mobile-friendly design Backend: - ✅ FastAPI static file serving - ✅ Simplified server mode (main_simple.py) - ✅ Improved startup script with port auto-selection - ✅ PostgreSQL schema ready (requires setup) Database: - ✅ SQLite populated with 85 Flipper Zero signatures - ✅ Device matching system operational - ✅ Frequency-based search working Documentation: - ✅ PHASE_3_COMPLETE.md - Technical summary - ✅ WEB_INTERFACE_README.md - User guide - ✅ WEBAPP_STARTUP_GUIDE.md - Troubleshooting - ✅ POSTGRESQL_SETUP_EXPLANATION.md - DB setup guide - ✅ DATABASE_POPULATION_SUCCESS.md - Import report - ✅ DEVICE_IDENTIFICATION_REPORT.md - Matching analysis Files Created: - templates/index.html (260 lines) - static/css/main.css (500 lines) - static/js/*.js (760 lines total) - src/api/main_simple.py (simplified server) - start_web.sh (auto port selection) Status: Production MVP Ready Next: Phase 4 - API & Integration 🛰️ Generated with Claude Code https://claude.com/claude-code Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,205 @@
|
||||
// GigLez - Map Visualization
|
||||
|
||||
let map;
|
||||
let markerLayer;
|
||||
let markerClusterGroup;
|
||||
let capturesData = [];
|
||||
|
||||
// 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);
|
||||
}
|
||||
|
||||
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', () => {
|
||||
renderMarkers();
|
||||
});
|
||||
|
||||
// Heatmap toggle (placeholder)
|
||||
document.getElementById('heatmap-toggle').addEventListener('change', (e) => {
|
||||
if (e.target.checked) {
|
||||
alert('Heatmap view coming soon!');
|
||||
e.target.checked = false;
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
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.file_hash}')" 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 viewCaptureDetails(fileHash) {
|
||||
// Navigate to detail page (to be implemented)
|
||||
alert(`Viewing details for capture: ${fileHash}`);
|
||||
}
|
||||
|
||||
// Export for external use
|
||||
window.reloadMapData = loadCaptures;
|
||||
Reference in New Issue
Block a user