Replace monolithic CLI and web server with route-based Flask API. New routes: api_commands, api_build, api_can, api_monitor, api_ota, api_tunnel. Add honeypot security dashboard with real-time SSE, MITRE ATT&CK mapping, kill chain analysis. New TUI with commander/help modules. Add session management, tunnel proxy core, CAN bus data store. Docker support.
833 lines
25 KiB
JavaScript
833 lines
25 KiB
JavaScript
/**
|
|
* 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: '© <a href="https://carto.com/">CARTO</a>',
|
|
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: `<svg width="24" height="24" viewBox="0 0 24 24" fill="none">
|
|
<circle cx="12" cy="12" r="10" fill="#f87171" fill-opacity="0.3"/>
|
|
<circle cx="12" cy="12" r="6" fill="#f87171"/>
|
|
<circle cx="12" cy="12" r="3" fill="#fff"/>
|
|
</svg>`,
|
|
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(`
|
|
<strong>${scanner.id}</strong><br>
|
|
RSSI: ${scanner.last_rssi || '-'} dBm<br>
|
|
Distance: ${scanner.estimated_distance || '-'} m
|
|
`);
|
|
}
|
|
|
|
// Update popup content
|
|
scannerMarkers[scanner.id].setPopupContent(`
|
|
<strong>${scanner.id}</strong><br>
|
|
RSSI: ${scanner.last_rssi || '-'} dBm<br>
|
|
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 = '<div class="empty">No scanners active</div>';
|
|
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 `
|
|
<div class="scanner-item">
|
|
<div class="scanner-id">${s.id}</div>
|
|
<div class="scanner-details">
|
|
Pos: ${posStr} |
|
|
RSSI: ${s.last_rssi !== null ? s.last_rssi + ' dBm' : '-'} |
|
|
Dist: ${s.estimated_distance !== null ? s.estimated_distance + 'm' : '-'}
|
|
</div>
|
|
</div>
|
|
`;
|
|
}).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);
|
|
});
|