/** * MLAT (Multilateration) Visualization for ESPILON C2 * Supports Map view (Leaflet/OSM) and Plan view (Canvas) * Supports both GPS (lat/lon) and Local (x/y in meters) coordinates */ // ============================================================ // State // ============================================================ let currentView = 'map'; let coordMode = 'gps'; // 'gps' or 'local' let map = null; let planCanvas = null; let planCtx = null; let planImage = null; // Plan settings for local coordinate mode let planSettings = { width: 50, // meters height: 30, // meters originX: 0, // meters offset originY: 0 // meters offset }; // Plan display options let showGrid = true; let showLabels = true; let planZoom = 1.0; // 1.0 = 100% let panOffset = { x: 0, y: 0 }; // Pan offset in pixels let isPanning = false; let lastPanPos = { x: 0, y: 0 }; // Markers let scannerMarkers = {}; let targetMarker = null; let rangeCircles = {}; // Data let scanners = []; let target = null; // ============================================================ // Map View (Leaflet) - GPS Mode // ============================================================ function initMap() { if (map) return; const centerLat = parseFloat(document.getElementById('map-center-lat').value) || 48.8566; const centerLon = parseFloat(document.getElementById('map-center-lon').value) || 2.3522; const zoom = parseInt(document.getElementById('map-zoom').value) || 18; map = L.map('leaflet-map', { center: [centerLat, centerLon], zoom: zoom, zoomControl: true }); // Dark tile layer (CartoDB Dark Matter) L.tileLayer('https://{s}.basemaps.cartocdn.com/dark_all/{z}/{x}/{y}{r}.png', { attribution: '© CARTO', subdomains: 'abcd', maxZoom: 20 }).addTo(map); } function createScannerIcon() { return L.divIcon({ className: 'scanner-marker', iconSize: [16, 16], iconAnchor: [8, 8] }); } function createTargetIcon() { return L.divIcon({ className: 'target-marker', html: ` `, iconSize: [24, 24], iconAnchor: [12, 12] }); } function updateMapMarkers() { if (!map) return; // Only show GPS mode scanners on map const gpsFilteredScanners = scanners.filter(s => s.position && s.position.lat !== undefined); const currentIds = new Set(gpsFilteredScanners.map(s => s.id)); // Remove old markers for (const id in scannerMarkers) { if (!currentIds.has(id)) { map.removeLayer(scannerMarkers[id]); delete scannerMarkers[id]; if (rangeCircles[id]) { map.removeLayer(rangeCircles[id]); delete rangeCircles[id]; } } } // Update/add scanner markers for (const scanner of gpsFilteredScanners) { const pos = scanner.position; if (scannerMarkers[scanner.id]) { scannerMarkers[scanner.id].setLatLng([pos.lat, pos.lon]); } else { scannerMarkers[scanner.id] = L.marker([pos.lat, pos.lon], { icon: createScannerIcon() }).addTo(map); scannerMarkers[scanner.id].bindPopup(` ${scanner.id}
RSSI: ${scanner.last_rssi || '-'} dBm
Distance: ${scanner.estimated_distance || '-'} m `); } // Update popup content scannerMarkers[scanner.id].setPopupContent(` ${scanner.id}
RSSI: ${scanner.last_rssi || '-'} dBm
Distance: ${scanner.estimated_distance || '-'} m `); // Update range circle if (scanner.estimated_distance) { if (rangeCircles[scanner.id]) { rangeCircles[scanner.id].setLatLng([pos.lat, pos.lon]); rangeCircles[scanner.id].setRadius(scanner.estimated_distance); } else { rangeCircles[scanner.id] = L.circle([pos.lat, pos.lon], { radius: scanner.estimated_distance, color: 'rgba(129, 140, 248, 0.4)', fillColor: 'rgba(129, 140, 248, 0.1)', fillOpacity: 0.3, weight: 2 }).addTo(map); } } } // Update target marker (GPS only) if (target && target.lat !== undefined) { if (targetMarker) { targetMarker.setLatLng([target.lat, target.lon]); } else { targetMarker = L.marker([target.lat, target.lon], { icon: createTargetIcon() }).addTo(map); } } else if (targetMarker) { map.removeLayer(targetMarker); targetMarker = null; } } function centerMap() { if (!map) return; const lat = parseFloat(document.getElementById('map-center-lat').value); const lon = parseFloat(document.getElementById('map-center-lon').value); const zoom = parseInt(document.getElementById('map-zoom').value); map.setView([lat, lon], zoom); } function fitMapToBounds() { if (!map || scanners.length === 0) return; const points = scanners .filter(s => s.position && s.position.lat !== undefined) .map(s => [s.position.lat, s.position.lon]); if (target && target.lat !== undefined) { points.push([target.lat, target.lon]); } if (points.length > 0) { map.fitBounds(points, { padding: [50, 50] }); } } // ============================================================ // Plan View (Canvas) - Supports both GPS and Local coords // ============================================================ function initPlanCanvas() { planCanvas = document.getElementById('plan-canvas'); if (!planCanvas) return; planCtx = planCanvas.getContext('2d'); resizePlanCanvas(); setupPlanPanning(); window.addEventListener('resize', resizePlanCanvas); } function resizePlanCanvas() { if (!planCanvas) return; const wrapper = planCanvas.parentElement; planCanvas.width = wrapper.clientWidth - 32; planCanvas.height = wrapper.clientHeight - 32; drawPlan(); } function drawPlan() { if (!planCtx) return; const ctx = planCtx; const w = planCanvas.width; const h = planCanvas.height; // Clear (before transform) ctx.setTransform(1, 0, 0, 1, 0, 0); ctx.fillStyle = '#06060a'; ctx.fillRect(0, 0, w, h); // Apply zoom and pan transform const centerX = w / 2; const centerY = h / 2; ctx.setTransform(planZoom, 0, 0, planZoom, centerX - centerX * planZoom + panOffset.x, centerY - centerY * planZoom + panOffset.y); // Draw plan image if loaded if (planImage) { ctx.drawImage(planImage, 0, 0, w, h); } // Draw grid (always when enabled, on top of image) if (showGrid) { drawGrid(ctx, w, h, !!planImage); } // Draw range circles for (const scanner of scanners) { if (scanner.estimated_distance) { drawPlanRangeCircle(ctx, scanner); } } // Draw scanners for (const scanner of scanners) { drawPlanScanner(ctx, scanner); } // Draw target if (target) { drawPlanTarget(ctx); } // Reset transform for any UI overlay ctx.setTransform(1, 0, 0, 1, 0, 0); } function drawGrid(ctx, w, h, hasImage = false) { // More visible grid when over image ctx.strokeStyle = hasImage ? 'rgba(129, 140, 248, 0.4)' : '#21262d'; ctx.lineWidth = hasImage ? 1.5 : 1; ctx.font = '10px monospace'; ctx.fillStyle = hasImage ? 'rgba(200, 200, 200, 0.9)' : '#484f58'; if (coordMode === 'local') { // Draw grid based on plan size in meters const metersPerPixelX = planSettings.width / w; const metersPerPixelY = planSettings.height / h; // Grid every 5 meters const gridMeters = 5; const gridPixelsX = gridMeters / metersPerPixelX; const gridPixelsY = gridMeters / metersPerPixelY; // Vertical lines for (let x = gridPixelsX; x < w; x += gridPixelsX) { ctx.beginPath(); ctx.moveTo(x, 0); ctx.lineTo(x, h); ctx.stroke(); // Label if (showLabels) { const meters = (x * metersPerPixelX + planSettings.originX).toFixed(0); if (hasImage) { // Background for readability ctx.fillStyle = 'rgba(0, 0, 0, 0.6)'; ctx.fillRect(x + 1, 2, 25, 12); ctx.fillStyle = 'rgba(200, 200, 200, 0.9)'; } ctx.fillText(`${meters}m`, x + 2, 12); } } // Horizontal lines for (let y = gridPixelsY; y < h; y += gridPixelsY) { ctx.beginPath(); ctx.moveTo(0, y); ctx.lineTo(w, y); ctx.stroke(); // Label if (showLabels) { const meters = (planSettings.height - y * metersPerPixelY + planSettings.originY).toFixed(0); if (hasImage) { ctx.fillStyle = 'rgba(0, 0, 0, 0.6)'; ctx.fillRect(1, y - 13, 25, 12); ctx.fillStyle = 'rgba(200, 200, 200, 0.9)'; } ctx.fillText(`${meters}m`, 2, y - 2); } } // Size label if (showLabels) { ctx.fillStyle = hasImage ? 'rgba(129, 140, 248, 0.9)' : '#818cf8'; if (hasImage) { ctx.fillStyle = 'rgba(0, 0, 0, 0.6)'; ctx.fillRect(w - 65, h - 16, 62, 14); ctx.fillStyle = 'rgba(129, 140, 248, 0.9)'; } ctx.fillText(`${planSettings.width}x${planSettings.height}m`, w - 60, h - 5); } } else { // Simple grid for GPS mode const gridSize = 50; for (let x = gridSize; x < w; x += gridSize) { ctx.beginPath(); ctx.moveTo(x, 0); ctx.lineTo(x, h); ctx.stroke(); } for (let y = gridSize; y < h; y += gridSize) { ctx.beginPath(); ctx.moveTo(0, y); ctx.lineTo(w, y); ctx.stroke(); } } } function toggleGrid() { showGrid = !showGrid; document.getElementById('grid-toggle').classList.toggle('active', showGrid); drawPlan(); } function toggleLabels() { showLabels = !showLabels; document.getElementById('labels-toggle').classList.toggle('active', showLabels); drawPlan(); } function zoomPlan(direction) { const zoomStep = 0.25; const minZoom = 0.25; const maxZoom = 4.0; if (direction > 0) { planZoom = Math.min(maxZoom, planZoom + zoomStep); } else { planZoom = Math.max(minZoom, planZoom - zoomStep); } updateZoomDisplay(); drawPlan(); } function resetZoom() { planZoom = 1.0; panOffset = { x: 0, y: 0 }; updateZoomDisplay(); drawPlan(); } function updateZoomDisplay() { const el = document.getElementById('zoom-level'); if (el) { el.textContent = Math.round(planZoom * 100) + '%'; } } function setupPlanPanning() { if (!planCanvas) return; // Mouse wheel zoom planCanvas.addEventListener('wheel', (e) => { e.preventDefault(); const direction = e.deltaY < 0 ? 1 : -1; zoomPlan(direction); }, { passive: false }); // Pan with mouse drag planCanvas.addEventListener('mousedown', (e) => { if (e.button === 0) { // Left click isPanning = true; lastPanPos = { x: e.clientX, y: e.clientY }; planCanvas.style.cursor = 'grabbing'; } }); planCanvas.addEventListener('mousemove', (e) => { if (isPanning) { const dx = e.clientX - lastPanPos.x; const dy = e.clientY - lastPanPos.y; panOffset.x += dx; panOffset.y += dy; lastPanPos = { x: e.clientX, y: e.clientY }; drawPlan(); } }); planCanvas.addEventListener('mouseup', () => { isPanning = false; planCanvas.style.cursor = 'grab'; }); planCanvas.addEventListener('mouseleave', () => { isPanning = false; planCanvas.style.cursor = 'grab'; }); planCanvas.style.cursor = 'grab'; } function worldToCanvas(pos) { const w = planCanvas.width; const h = planCanvas.height; if (coordMode === 'local' || (pos.x !== undefined && pos.lat === undefined)) { // Local coordinates (x, y in meters) const x = pos.x !== undefined ? pos.x : 0; const y = pos.y !== undefined ? pos.y : 0; const canvasX = ((x - planSettings.originX) / planSettings.width) * w; const canvasY = h - ((y - planSettings.originY) / planSettings.height) * h; return { x: Math.max(0, Math.min(w, canvasX)), y: Math.max(0, Math.min(h, canvasY)) }; } else { // GPS coordinates (lat, lon) const centerLat = parseFloat(document.getElementById('map-center-lat').value) || 48.8566; const centerLon = parseFloat(document.getElementById('map-center-lon').value) || 2.3522; const range = 0.002; // ~200m const canvasX = ((pos.lon - centerLon + range) / (2 * range)) * w; const canvasY = ((centerLat + range - pos.lat) / (2 * range)) * h; return { x: Math.max(0, Math.min(w, canvasX)), y: Math.max(0, Math.min(h, canvasY)) }; } } function distanceToPixels(distance) { if (coordMode === 'local') { // Direct conversion: distance in meters to pixels const pixelsPerMeter = planCanvas.width / planSettings.width; return distance * pixelsPerMeter; } else { // GPS mode: approximate conversion const range = 0.002; // degrees const rangeMeters = range * 111000; // ~222m const pixelsPerMeter = planCanvas.width / rangeMeters; return distance * pixelsPerMeter; } } function drawPlanRangeCircle(ctx, scanner) { const pos = scanner.position; if (!pos) return; // Check if position is valid for current mode if (coordMode === 'local' && pos.x === undefined && pos.lat !== undefined) return; if (coordMode === 'gps' && pos.lat === undefined && pos.x !== undefined) return; const canvasPos = worldToCanvas(pos); const radius = distanceToPixels(scanner.estimated_distance); ctx.beginPath(); ctx.arc(canvasPos.x, canvasPos.y, radius, 0, Math.PI * 2); ctx.strokeStyle = 'rgba(129, 140, 248, 0.3)'; ctx.lineWidth = 2; ctx.stroke(); } function drawPlanScanner(ctx, scanner) { const pos = scanner.position; if (!pos) return; // Check if position is valid const hasGPS = pos.lat !== undefined; const hasLocal = pos.x !== undefined; if (!hasGPS && !hasLocal) return; const canvasPos = worldToCanvas(pos); // Dot ctx.beginPath(); ctx.arc(canvasPos.x, canvasPos.y, 8, 0, Math.PI * 2); ctx.fillStyle = '#818cf8'; ctx.fill(); // Label ctx.font = '12px monospace'; ctx.fillStyle = '#c9d1d9'; ctx.textAlign = 'center'; ctx.fillText(scanner.id, canvasPos.x, canvasPos.y - 15); // RSSI if (scanner.last_rssi !== null) { ctx.font = '10px monospace'; ctx.fillStyle = '#484f58'; ctx.fillText(`${scanner.last_rssi} dBm`, canvasPos.x, canvasPos.y + 20); } ctx.textAlign = 'left'; } function drawPlanTarget(ctx) { if (!target) return; const hasGPS = target.lat !== undefined; const hasLocal = target.x !== undefined; if (!hasGPS && !hasLocal) return; const canvasPos = worldToCanvas(target); // Glow ctx.beginPath(); ctx.arc(canvasPos.x, canvasPos.y, 20, 0, Math.PI * 2); ctx.fillStyle = 'rgba(248, 113, 113, 0.3)'; ctx.fill(); // Cross ctx.strokeStyle = '#f87171'; ctx.lineWidth = 3; ctx.beginPath(); ctx.moveTo(canvasPos.x - 12, canvasPos.y - 12); ctx.lineTo(canvasPos.x + 12, canvasPos.y + 12); ctx.stroke(); ctx.beginPath(); ctx.moveTo(canvasPos.x + 12, canvasPos.y - 12); ctx.lineTo(canvasPos.x - 12, canvasPos.y + 12); ctx.stroke(); // Label ctx.font = 'bold 12px monospace'; ctx.fillStyle = '#f87171'; ctx.textAlign = 'center'; ctx.fillText('TARGET', canvasPos.x, canvasPos.y - 25); ctx.textAlign = 'left'; } // ============================================================ // Plan Image Upload & Calibration // ============================================================ function uploadPlanImage(input) { const file = input.files[0]; if (!file) return; const reader = new FileReader(); reader.onload = function(e) { planImage = new Image(); planImage.onload = function() { var btn = document.getElementById('calibrate-btn'); if (btn) btn.disabled = false; drawPlan(); }; planImage.src = e.target.result; }; reader.readAsDataURL(file); } function calibratePlan() { alert('Calibration: Set the plan dimensions in Plan Settings panel.\n\nThe grid will map x,y meters to your uploaded image.'); drawPlan(); } function clearPlan() { planImage = null; var btn = document.getElementById('calibrate-btn'); if (btn) btn.disabled = true; drawPlan(); } function applyPlanSettings() { planSettings.width = parseFloat(document.getElementById('plan-width').value) || 50; planSettings.height = parseFloat(document.getElementById('plan-height').value) || 30; planSettings.originX = parseFloat(document.getElementById('plan-origin-x').value) || 0; planSettings.originY = parseFloat(document.getElementById('plan-origin-y').value) || 0; updateSizeDisplay(); drawPlan(); } function adjustPlanSize(delta) { // Adjust both width and height proportionally const minSize = 10; const maxSize = 500; planSettings.width = Math.max(minSize, Math.min(maxSize, planSettings.width + delta)); planSettings.height = Math.max(minSize, Math.min(maxSize, planSettings.height + Math.round(delta * 0.6))); // Update input fields in sidebar document.getElementById('plan-width').value = planSettings.width; document.getElementById('plan-height').value = planSettings.height; updateSizeDisplay(); drawPlan(); } function updateSizeDisplay() { const el = document.getElementById('size-display'); if (el) { el.textContent = `${planSettings.width}x${planSettings.height}m`; } } // ============================================================ // View Switching // ============================================================ function switchView(view) { currentView = view; // Update buttons document.querySelectorAll('.view-btn').forEach(btn => { btn.classList.toggle('active', btn.dataset.view === view); }); // Update views document.getElementById('map-view').classList.toggle('active', view === 'map'); document.getElementById('plan-view').classList.toggle('active', view === 'plan'); // Show/hide settings panels based on view document.getElementById('map-settings').style.display = view === 'map' ? 'block' : 'none'; document.getElementById('plan-settings').style.display = view === 'plan' ? 'block' : 'none'; // Initialize view if needed if (view === 'map') { setTimeout(() => { if (!map) initMap(); else map.invalidateSize(); updateMapMarkers(); }, 100); } else { if (!planCanvas) initPlanCanvas(); else resizePlanCanvas(); } } // ============================================================ // UI Updates // ============================================================ function updateCoordMode(mode) { coordMode = mode; const modeDisplay = document.getElementById('coord-mode'); const coord1Label = document.getElementById('target-coord1-label'); const coord2Label = document.getElementById('target-coord2-label'); if (mode === 'gps') { modeDisplay.textContent = 'GPS'; coord1Label.textContent = 'Latitude'; coord2Label.textContent = 'Longitude'; } else { modeDisplay.textContent = 'Local'; coord1Label.textContent = 'X (m)'; coord2Label.textContent = 'Y (m)'; } } function updateTargetInfo(targetData) { const coord1El = document.getElementById('target-coord1'); const coord2El = document.getElementById('target-coord2'); if (targetData && targetData.position) { const pos = targetData.position; if (pos.lat !== undefined) { coord1El.textContent = pos.lat.toFixed(6); coord2El.textContent = pos.lon.toFixed(6); } else if (pos.x !== undefined) { coord1El.textContent = pos.x.toFixed(2) + ' m'; coord2El.textContent = pos.y.toFixed(2) + ' m'; } else { coord1El.textContent = '-'; coord2El.textContent = '-'; } document.getElementById('target-confidence').textContent = ((targetData.confidence || 0) * 100).toFixed(0) + '%'; document.getElementById('target-age').textContent = (targetData.age_seconds || 0).toFixed(1) + 's ago'; // Store for rendering target = pos; } else { coord1El.textContent = '-'; coord2El.textContent = '-'; document.getElementById('target-confidence').textContent = '-'; document.getElementById('target-age').textContent = '-'; target = null; } } function updateScannerList(scannersData) { scanners = scannersData || []; const list = document.getElementById('scanner-list'); document.getElementById('scanner-count').textContent = scanners.length; if (scanners.length === 0) { list.innerHTML = '
No scanners active
'; return; } list.innerHTML = scanners.map(s => { const pos = s.position || {}; let posStr; if (pos.lat !== undefined) { posStr = `(${pos.lat.toFixed(4)}, ${pos.lon.toFixed(4)})`; } else if (pos.x !== undefined) { posStr = `(${pos.x.toFixed(1)}m, ${pos.y.toFixed(1)}m)`; } else { posStr = '(-, -)'; } return `
${s.id}
Pos: ${posStr} | RSSI: ${s.last_rssi !== null ? s.last_rssi + ' dBm' : '-'} | Dist: ${s.estimated_distance !== null ? s.estimated_distance + 'm' : '-'}
`; }).join(''); } function updateConfig(config) { if (!config) return; document.getElementById('config-rssi').value = config.rssi_at_1m || -40; document.getElementById('config-n').value = config.path_loss_n || 2.5; document.getElementById('config-smooth').value = config.smoothing_window || 5; } // ============================================================ // API Functions // ============================================================ async function fetchState() { try { const res = await fetch('/api/mlat/state'); const state = await res.json(); // Update coordinate mode from server if (state.coord_mode) { updateCoordMode(state.coord_mode); } updateTargetInfo(state.target); updateScannerList(state.scanners); if (state.config) { updateConfig(state.config); } // Update visualization if (currentView === 'map') { updateMapMarkers(); } else { drawPlan(); } } catch (e) { console.error('Failed to fetch MLAT state:', e); } } async function saveConfig() { const config = { rssi_at_1m: parseFloat(document.getElementById('config-rssi').value), path_loss_n: parseFloat(document.getElementById('config-n').value), smoothing_window: parseInt(document.getElementById('config-smooth').value) }; try { await fetch('/api/mlat/config', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(config) }); console.log('Config saved'); } catch (e) { console.error('Failed to save config:', e); } } async function clearData() { try { await fetch('/api/mlat/clear', { method: 'POST' }); fetchState(); } catch (e) { console.error('Failed to clear data:', e); } } // ============================================================ // Initialization // ============================================================ document.addEventListener('DOMContentLoaded', () => { // Initialize map view by default initMap(); initPlanCanvas(); // Initialize displays updateZoomDisplay(); updateSizeDisplay(); // Start polling fetchState(); setInterval(fetchState, 2000); });