Files
PLFM_RADAR/9_Firmware/9_3_GUI/GUI_PyQt_Map.py
T
Jason 57de32b172 fix: resolve all ruff lint errors across V6+ GUIs, v7 module, and FPGA cosim scripts
Fixes 25 remaining manual lint errors after auto-fix pass (94 auto-fixed earlier):
- GUI_V6.py: noqa on availability imports, bare except, unused vars, F811 redefs
- GUI_V6_Demo.py: unused app variable
- v7/models.py: noqa F401 on 8 try/except availability-check imports
- FPGA cosim: unused header/status/span vars, ambiguous 'l' renamed to 'line',
  E701 while-on-one-line split, F841 padding vars annotated

Also adds v7/ module, GUI_PyQt_Map.py, and GUI_V7_PyQt.py to version control.
Expands CI lint job to cover all 21 maintained Python files (was 4).

All 58 Python tests pass. Zero ruff errors on all target files.
2026-04-08 19:11:40 +03:00

1663 lines
59 KiB
Python

#!/usr/bin/env python3
"""
PLFM Radar Dashboard - PyQt6 Edition with Embedded Leaflet Map
===============================================================
A professional-grade radar tracking GUI using PyQt6 with embedded web-based
Leaflet.js maps for real-time target visualization.
Features:
- Embedded interactive Leaflet map with OpenStreetMap tiles
- Real-time target tracking and visualization
- Python-to-JavaScript bridge for seamless updates
- Dark theme UI matching existing radar dashboard style
- Support for multiple tile servers (OSM, Google, satellite)
- Marker clustering for dense target environments
- Coverage area visualization
- Target trails/history
Author: PLFM Radar Team
Version: 1.0.0
"""
import sys
import json
import math
import time
import random
import logging
from dataclasses import dataclass, asdict
from typing import List, Dict, Optional, Tuple
from enum import Enum
# PyQt6 imports
from PyQt6.QtWidgets import (
QApplication, QMainWindow, QWidget, QVBoxLayout, QHBoxLayout,
QTabWidget, QLabel, QPushButton, QComboBox, QSpinBox, QDoubleSpinBox,
QGroupBox, QGridLayout, QSplitter, QFrame, QStatusBar, QCheckBox, QTableWidget, QTableWidgetItem,
QHeaderView
)
from PyQt6.QtCore import (
Qt, QTimer, pyqtSignal, pyqtSlot, QObject
)
from PyQt6.QtGui import (
QFont, QColor, QPalette
)
from PyQt6.QtWebEngineWidgets import QWebEngineView
from PyQt6.QtWebChannel import QWebChannel
# Configure logging
logging.basicConfig(
level=logging.INFO,
format='%(asctime)s - %(levelname)s - %(message)s'
)
logger = logging.getLogger(__name__)
# =============================================================================
# Dark Theme Colors (matching existing radar dashboard)
# =============================================================================
DARK_BG = "#2b2b2b"
DARK_FG = "#e0e0e0"
DARK_ACCENT = "#3c3f41"
DARK_HIGHLIGHT = "#4e5254"
DARK_BORDER = "#555555"
DARK_TEXT = "#cccccc"
DARK_BUTTON = "#3c3f41"
DARK_BUTTON_HOVER = "#4e5254"
DARK_SUCCESS = "#4CAF50"
DARK_WARNING = "#FFC107"
DARK_ERROR = "#F44336"
DARK_INFO = "#2196F3"
# =============================================================================
# Data Classes
# =============================================================================
@dataclass
class RadarTarget:
"""Represents a detected radar target"""
id: int
range: float # Range in meters
velocity: float # Velocity in m/s (positive = approaching)
azimuth: float # Azimuth angle in degrees
elevation: float # Elevation angle in degrees
latitude: float = 0.0
longitude: float = 0.0
snr: float = 0.0 # Signal-to-noise ratio in dB
timestamp: float = 0.0
track_id: int = -1
classification: str = "unknown"
def to_dict(self) -> dict:
"""Convert to dictionary for JSON serialization"""
return asdict(self)
@dataclass
class GPSData:
"""GPS position and orientation data"""
latitude: float
longitude: float
altitude: float
pitch: float # Pitch angle in degrees
heading: float = 0.0 # Heading in degrees (0 = North)
timestamp: float = 0.0
def to_dict(self) -> dict:
return asdict(self)
@dataclass
class RadarSettings:
"""Radar system configuration"""
system_frequency: float = 10e9 # Hz
chirp_duration_1: float = 30e-6 # Long chirp duration (s)
chirp_duration_2: float = 0.5e-6 # Short chirp duration (s)
chirps_per_position: int = 32
freq_min: float = 10e6 # Hz
freq_max: float = 30e6 # Hz
prf1: float = 1000 # PRF 1 (Hz)
prf2: float = 2000 # PRF 2 (Hz)
max_distance: float = 50000 # Max detection range (m)
coverage_radius: float = 50000 # Map coverage radius (m)
class TileServer(Enum):
"""Available map tile servers"""
OPENSTREETMAP = "osm"
GOOGLE_MAPS = "google"
GOOGLE_SATELLITE = "google_sat"
GOOGLE_HYBRID = "google_hybrid"
ESRI_SATELLITE = "esri_sat"
# =============================================================================
# JavaScript Bridge - Enables Python <-> JavaScript communication
# =============================================================================
class MapBridge(QObject):
"""
Bridge object exposed to JavaScript for bidirectional communication.
This allows Python to call JavaScript functions and vice versa.
"""
# Signals emitted when JS calls Python
mapClicked = pyqtSignal(float, float) # lat, lon
markerClicked = pyqtSignal(int) # target_id
mapReady = pyqtSignal()
def __init__(self, parent=None):
super().__init__(parent)
self._map_ready = False
@pyqtSlot(float, float)
def onMapClick(self, lat: float, lon: float):
"""Called from JavaScript when map is clicked"""
logger.debug(f"Map clicked at: {lat}, {lon}")
self.mapClicked.emit(lat, lon)
@pyqtSlot(int)
def onMarkerClick(self, target_id: int):
"""Called from JavaScript when a target marker is clicked"""
logger.debug(f"Marker clicked: Target #{target_id}")
self.markerClicked.emit(target_id)
@pyqtSlot()
def onMapReady(self):
"""Called from JavaScript when map is fully initialized"""
logger.info("Map is ready")
self._map_ready = True
self.mapReady.emit()
@pyqtSlot(str)
def logFromJS(self, message: str):
"""Receive log messages from JavaScript"""
logger.debug(f"[JS] {message}")
@property
def is_ready(self) -> bool:
return self._map_ready
# =============================================================================
# Map Widget - Embedded Leaflet Map
# =============================================================================
class RadarMapWidget(QWidget):
"""
Custom widget embedding a Leaflet.js map via QWebEngineView.
Provides methods for updating radar position, targets, and coverage.
"""
targetSelected = pyqtSignal(int) # Emitted when a target is selected
def __init__(self, parent=None):
super().__init__(parent)
# State
self._radar_position = GPSData(
latitude=40.7128, # Default: New York City
longitude=-74.0060,
altitude=100.0,
pitch=0.0
)
self._targets: List[RadarTarget] = []
self._coverage_radius = 50000 # meters
self._tile_server = TileServer.OPENSTREETMAP
self._show_coverage = True
self._show_trails = False
self._target_history: Dict[int, List[Tuple[float, float]]] = {}
# Setup UI
self._setup_ui()
# Setup bridge
self._bridge = MapBridge(self)
self._bridge.mapReady.connect(self._on_map_ready)
self._bridge.markerClicked.connect(self._on_marker_clicked)
# Setup web channel
self._channel = QWebChannel()
self._channel.registerObject("bridge", self._bridge)
self._web_view.page().setWebChannel(self._channel)
# Load map
self._load_map()
def _setup_ui(self):
"""Setup the widget UI"""
layout = QVBoxLayout(self)
layout.setContentsMargins(0, 0, 0, 0)
# Control bar
control_bar = QFrame()
control_bar.setStyleSheet(f"background-color: {DARK_ACCENT}; border-radius: 4px;")
control_layout = QHBoxLayout(control_bar)
control_layout.setContentsMargins(8, 4, 8, 4)
# Tile server selector
self._tile_combo = QComboBox()
self._tile_combo.addItem("OpenStreetMap", TileServer.OPENSTREETMAP)
self._tile_combo.addItem("Google Maps", TileServer.GOOGLE_MAPS)
self._tile_combo.addItem("Google Satellite", TileServer.GOOGLE_SATELLITE)
self._tile_combo.addItem("Google Hybrid", TileServer.GOOGLE_HYBRID)
self._tile_combo.addItem("ESRI Satellite", TileServer.ESRI_SATELLITE)
self._tile_combo.currentIndexChanged.connect(self._on_tile_server_changed)
self._tile_combo.setStyleSheet(f"""
QComboBox {{
background-color: {DARK_BUTTON};
color: {DARK_FG};
border: 1px solid {DARK_BORDER};
padding: 4px 8px;
border-radius: 4px;
}}
""")
control_layout.addWidget(QLabel("Tiles:"))
control_layout.addWidget(self._tile_combo)
# Coverage toggle
self._coverage_check = QCheckBox("Show Coverage")
self._coverage_check.setChecked(True)
self._coverage_check.stateChanged.connect(self._on_coverage_toggled)
control_layout.addWidget(self._coverage_check)
# Trails toggle
self._trails_check = QCheckBox("Show Trails")
self._trails_check.setChecked(False)
self._trails_check.stateChanged.connect(self._on_trails_toggled)
control_layout.addWidget(self._trails_check)
# Center on radar button
center_btn = QPushButton("Center on Radar")
center_btn.clicked.connect(self._center_on_radar)
center_btn.setStyleSheet(f"""
QPushButton {{
background-color: {DARK_BUTTON};
color: {DARK_FG};
border: 1px solid {DARK_BORDER};
padding: 4px 12px;
border-radius: 4px;
}}
QPushButton:hover {{
background-color: {DARK_BUTTON_HOVER};
}}
""")
control_layout.addWidget(center_btn)
# Fit all button
fit_btn = QPushButton("Fit All Targets")
fit_btn.clicked.connect(self._fit_all_targets)
fit_btn.setStyleSheet(center_btn.styleSheet())
control_layout.addWidget(fit_btn)
control_layout.addStretch()
# Status label
self._status_label = QLabel("Initializing map...")
self._status_label.setStyleSheet(f"color: {DARK_INFO};")
control_layout.addWidget(self._status_label)
layout.addWidget(control_bar)
# Web view for map
self._web_view = QWebEngineView()
self._web_view.setMinimumSize(400, 300)
layout.addWidget(self._web_view, stretch=1)
def _get_map_html(self) -> str:
"""Generate the complete HTML for the Leaflet map"""
return f'''<!DOCTYPE html>
<html>
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Radar Map</title>
<!-- Leaflet CSS -->
<link rel="stylesheet" href="https://unpkg.com/leaflet@1.9.4/dist/leaflet.css"
integrity="sha256-p4NxAoJBhIIN+hmNHrzRCf9tD/miZyoHS5obTRR9BMY="
crossorigin=""/>
<!-- Leaflet JS -->
<script src="https://unpkg.com/leaflet@1.9.4/dist/leaflet.js"
integrity="sha256-20nQCchB9co0qIjJZRGuk2/Z9VM+kNiyxNV1lvTlZBo="
crossorigin=""></script>
<!-- QWebChannel -->
<script src="qrc:///qtwebchannel/qwebchannel.js"></script>
<style>
* {{
margin: 0;
padding: 0;
box-sizing: border-box;
}}
html, body {{
height: 100%;
width: 100%;
font-family: 'Segoe UI', Arial, sans-serif;
}}
#map {{
height: 100%;
width: 100%;
background-color: {DARK_BG};
}}
.leaflet-container {{
background-color: {DARK_BG} !important;
}}
/* Custom popup styling */
.leaflet-popup-content-wrapper {{
background-color: {DARK_ACCENT};
color: {DARK_FG};
border-radius: 8px;
box-shadow: 0 4px 12px rgba(0,0,0,0.4);
}}
.leaflet-popup-tip {{
background-color: {DARK_ACCENT};
}}
.leaflet-popup-content {{
margin: 12px;
}}
.popup-title {{
font-size: 14px;
font-weight: bold;
color: #4e9eff;
margin-bottom: 8px;
border-bottom: 1px solid {DARK_BORDER};
padding-bottom: 6px;
}}
.popup-row {{
display: flex;
justify-content: space-between;
margin: 4px 0;
font-size: 12px;
}}
.popup-label {{
color: {DARK_TEXT};
}}
.popup-value {{
color: {DARK_FG};
font-weight: 500;
}}
.status-approaching {{
color: #F44336;
}}
.status-receding {{
color: #2196F3;
}}
.status-stationary {{
color: #9E9E9E;
}}
/* Legend */
.legend {{
background-color: {DARK_ACCENT};
color: {DARK_FG};
padding: 10px 14px;
border-radius: 6px;
font-size: 12px;
box-shadow: 0 2px 8px rgba(0,0,0,0.3);
}}
.legend-title {{
font-weight: bold;
margin-bottom: 8px;
color: #4e9eff;
}}
.legend-item {{
display: flex;
align-items: center;
margin: 4px 0;
}}
.legend-color {{
width: 14px;
height: 14px;
border-radius: 50%;
margin-right: 8px;
border: 1px solid white;
}}
</style>
</head>
<body>
<div id="map"></div>
<script>
// Global variables
var map;
var radarMarker;
var coverageCircle;
var targetMarkers = {{}};
var targetTrails = {{}}; // Polyline objects for display
var targetTrailHistory = {{}}; // Store position history even when trails hidden
var bridge = null;
var currentTileLayer = null;
var showCoverage = true;
var showTrails = false;
var maxTrailLength = 30; // Maximum number of points in trail
// Tile server configurations
var tileServers = {{
'osm': {{
url: 'https://{{s}}.tile.openstreetmap.org/{{z}}/{{x}}/{{y}}.png',
attribution: '&copy; <a href="https://www.openstreetmap.org/copyright">OpenStreetMap</a>',
maxZoom: 19
}},
'google': {{
url: 'https://mt0.google.com/vt/lyrs=m&hl=en&x={{x}}&y={{y}}&z={{z}}&s=Ga',
attribution: '&copy; Google Maps',
maxZoom: 22
}},
'google_sat': {{
url: 'https://mt0.google.com/vt/lyrs=s&hl=en&x={{x}}&y={{y}}&z={{z}}&s=Ga',
attribution: '&copy; Google Maps',
maxZoom: 22
}},
'google_hybrid': {{
url: 'https://mt0.google.com/vt/lyrs=y&hl=en&x={{x}}&y={{y}}&z={{z}}&s=Ga',
attribution: '&copy; Google Maps',
maxZoom: 22
}},
'esri_sat': {{
url: 'https://server.arcgisonline.com/ArcGIS/rest/services/World_Imagery/MapServer/tile/{{z}}/{{y}}/{{x}}',
attribution: '&copy; Esri',
maxZoom: 19
}}
}};
// Initialize map
function initMap() {{
console.log('Initializing map...');
// Create map centered on default position
map = L.map('map', {{
preferCanvas: true,
zoomControl: true
}}).setView([{self._radar_position.latitude}, {self._radar_position.longitude}], 10);
// Add default tile layer
setTileServer('osm');
// Create radar marker
var radarIcon = L.divIcon({{
className: 'radar-icon',
html: '<div style="' +
'background: radial-gradient(circle, #FF5252 0%, #D32F2F 100%);' +
'width: 24px; height: 24px;' +
'border-radius: 50%;' +
'border: 3px solid white;' +
'box-shadow: 0 2px 8px rgba(0,0,0,0.5);' +
'"></div>',
iconSize: [24, 24],
iconAnchor: [12, 12]
}});
radarMarker = L.marker(
[{self._radar_position.latitude}, {self._radar_position.longitude}],
{{ icon: radarIcon, zIndexOffset: 1000 }}
).addTo(map);
// Radar popup
updateRadarPopup();
// Coverage circle
coverageCircle = L.circle(
[{self._radar_position.latitude}, {self._radar_position.longitude}],
{{
radius: {self._coverage_radius},
color: '#FF5252',
fillColor: '#FF5252',
fillOpacity: 0.08,
weight: 2,
dashArray: '8, 8'
}}
).addTo(map);
// Add legend
addLegend();
// Map click handler
map.on('click', function(e) {{
if (bridge) {{
bridge.onMapClick(e.latlng.lat, e.latlng.lng);
}}
}});
console.log('Map initialized successfully');
}}
function setTileServer(serverId) {{
var config = tileServers[serverId];
if (!config) return;
if (currentTileLayer) {{
map.removeLayer(currentTileLayer);
}}
currentTileLayer = L.tileLayer(config.url, {{
attribution: config.attribution,
maxZoom: config.maxZoom
}}).addTo(map);
}}
function updateRadarPopup() {{
if (!radarMarker) return;
var content = '<div class="popup-title">Radar System</div>' +
'<div class="popup-row"><span class="popup-label">Latitude:</span><span class="popup-value">' +
radarMarker.getLatLng().lat.toFixed(6) + '</span></div>' +
'<div class="popup-row"><span class="popup-label">Longitude:</span><span class="popup-value">' +
radarMarker.getLatLng().lng.toFixed(6) + '</span></div>' +
'<div class="popup-row"><span class="popup-label">Status:</span><span class="popup-value status-approaching">Active</span></div>';
radarMarker.bindPopup(content);
}}
function addLegend() {{
var legend = L.control({{ position: 'bottomright' }});
legend.onAdd = function(map) {{
var div = L.DomUtil.create('div', 'legend');
div.innerHTML =
'<div class="legend-title">Target Legend</div>' +
'<div class="legend-item"><div class="legend-color" style="background:#F44336"></div>Approaching</div>' +
'<div class="legend-item"><div class="legend-color" style="background:#2196F3"></div>Receding</div>' +
'<div class="legend-item"><div class="legend-color" style="background:#9E9E9E"></div>Stationary</div>' +
'<div class="legend-item"><div class="legend-color" style="background:#FF5252"></div>Radar</div>';
return div;
}};
legend.addTo(map);
}}
// Update radar position
function updateRadarPosition(lat, lon, alt, pitch, heading) {{
if (!radarMarker || !coverageCircle) return;
var newPos = [lat, lon];
radarMarker.setLatLng(newPos);
coverageCircle.setLatLng(newPos);
updateRadarPopup();
if (bridge) {{
bridge.logFromJS('Radar position updated: ' + lat.toFixed(4) + ', ' + lon.toFixed(4));
}}
}}
// Update targets on map
function updateTargets(targetsJson) {{
var targets = JSON.parse(targetsJson);
// Track which target IDs are in this update
var currentIds = {{}};
targets.forEach(function(target) {{
currentIds[target.id] = true;
// Calculate position
var lat = target.latitude;
var lon = target.longitude;
// Determine color based on velocity
var color = getTargetColor(target.velocity);
var size = Math.max(10, Math.min(20, 10 + target.snr / 3));
// Always update trail history (even if trails not visible)
if (!targetTrailHistory[target.id]) {{
targetTrailHistory[target.id] = [];
}}
targetTrailHistory[target.id].push([lat, lon]);
if (targetTrailHistory[target.id].length > maxTrailLength) {{
targetTrailHistory[target.id].shift();
}}
// Create or update marker
if (targetMarkers[target.id]) {{
// Update existing marker position
targetMarkers[target.id].setLatLng([lat, lon]);
// Update marker icon (color may change with velocity)
var newIcon = L.divIcon({{
className: 'target-icon',
html: '<div style="' +
'background-color: ' + color + ';' +
'width: ' + size + 'px;' +
'height: ' + size + 'px;' +
'border-radius: 50%;' +
'border: 2px solid white;' +
'box-shadow: 0 2px 6px rgba(0,0,0,0.4);' +
'"></div>',
iconSize: [size, size],
iconAnchor: [size/2, size/2]
}});
targetMarkers[target.id].setIcon(newIcon);
// Update trail polyline if it exists and trails are visible
if (targetTrails[target.id]) {{
targetTrails[target.id].setLatLngs(targetTrailHistory[target.id]);
targetTrails[target.id].setStyle({{ color: color }});
}}
}} else {{
// Create new marker
var icon = L.divIcon({{
className: 'target-icon',
html: '<div style="' +
'background-color: ' + color + ';' +
'width: ' + size + 'px;' +
'height: ' + size + 'px;' +
'border-radius: 50%;' +
'border: 2px solid white;' +
'box-shadow: 0 2px 6px rgba(0,0,0,0.4);' +
'"></div>',
iconSize: [size, size],
iconAnchor: [size/2, size/2]
}});
var marker = L.marker([lat, lon], {{ icon: icon }})
.addTo(map);
// Add click handler
marker.on('click', function() {{
if (bridge) {{
bridge.onMarkerClick(target.id);
}}
}});
targetMarkers[target.id] = marker;
// Create trail polyline if trails are enabled
if (showTrails) {{
targetTrails[target.id] = L.polyline(targetTrailHistory[target.id], {{
color: color,
weight: 3,
opacity: 0.7,
lineCap: 'round',
lineJoin: 'round'
}}).addTo(map);
}}
}}
// Update popup
updateTargetPopup(target);
}});
// Remove markers for targets no longer present
for (var id in targetMarkers) {{
if (!currentIds[id]) {{
map.removeLayer(targetMarkers[id]);
delete targetMarkers[id];
if (targetTrails[id]) {{
map.removeLayer(targetTrails[id]);
delete targetTrails[id];
}}
// Also clean up trail history
delete targetTrailHistory[id];
}}
}}
}}
function updateTargetPopup(target) {{
if (!targetMarkers[target.id]) return;
var statusClass = target.velocity > 1 ? 'status-approaching' :
(target.velocity < -1 ? 'status-receding' : 'status-stationary');
var statusText = target.velocity > 1 ? 'Approaching' :
(target.velocity < -1 ? 'Receding' : 'Stationary');
var content = '<div class="popup-title">Target #' + target.id + '</div>' +
'<div class="popup-row"><span class="popup-label">Range:</span><span class="popup-value">' +
target.range.toFixed(1) + ' m</span></div>' +
'<div class="popup-row"><span class="popup-label">Velocity:</span><span class="popup-value">' +
target.velocity.toFixed(1) + ' m/s</span></div>' +
'<div class="popup-row"><span class="popup-label">Azimuth:</span><span class="popup-value">' +
target.azimuth.toFixed(1) + '&deg;</span></div>' +
'<div class="popup-row"><span class="popup-label">Elevation:</span><span class="popup-value">' +
target.elevation.toFixed(1) + '&deg;</span></div>' +
'<div class="popup-row"><span class="popup-label">SNR:</span><span class="popup-value">' +
target.snr.toFixed(1) + ' dB</span></div>' +
'<div class="popup-row"><span class="popup-label">Track ID:</span><span class="popup-value">' +
target.track_id + '</span></div>' +
'<div class="popup-row"><span class="popup-label">Status:</span><span class="popup-value ' +
statusClass + '">' + statusText + '</span></div>';
targetMarkers[target.id].bindPopup(content);
}}
function getTargetColor(velocity) {{
if (velocity > 50) return '#FF1744'; // Fast approaching - red
if (velocity > 10) return '#FF5252'; // Medium approaching - light red
if (velocity > 1) return '#FF8A65'; // Slow approaching - orange
if (velocity < -50) return '#1565C0'; // Fast receding - dark blue
if (velocity < -10) return '#2196F3'; // Medium receding - blue
if (velocity < -1) return '#64B5F6'; // Slow receding - light blue
return '#9E9E9E'; // Stationary - gray
}}
// Coverage circle controls
function setCoverageVisible(visible) {{
showCoverage = visible;
if (coverageCircle) {{
if (visible) {{
coverageCircle.addTo(map);
}} else {{
map.removeLayer(coverageCircle);
}}
}}
}}
function setCoverageRadius(radius) {{
if (coverageCircle) {{
coverageCircle.setRadius(radius);
}}
}}
// Trail controls
function setTrailsVisible(visible) {{
showTrails = visible;
if (visible) {{
// Create trails for all existing markers using stored history
for (var id in targetMarkers) {{
if (!targetTrails[id] && targetTrailHistory[id] && targetTrailHistory[id].length > 1) {{
// Get color from current marker position (approximate)
var color = '#4CAF50'; // Default green
targetTrails[id] = L.polyline(targetTrailHistory[id], {{
color: color,
weight: 3,
opacity: 0.7,
lineCap: 'round',
lineJoin: 'round'
}}).addTo(map);
}} else if (targetTrails[id]) {{
// Trail exists but may have been removed, re-add it
targetTrails[id].addTo(map);
}}
}}
}} else {{
// Hide all trails (but keep history)
for (var id in targetTrails) {{
map.removeLayer(targetTrails[id]);
}}
}}
if (bridge) {{
bridge.logFromJS('Trails visibility set to: ' + visible);
}}
}}
// View controls
function centerOnRadar() {{
if (radarMarker) {{
map.setView(radarMarker.getLatLng(), map.getZoom());
}}
}}
function fitAllTargets() {{
var bounds = L.latLngBounds([]);
if (radarMarker) {{
bounds.extend(radarMarker.getLatLng());
}}
for (var id in targetMarkers) {{
bounds.extend(targetMarkers[id].getLatLng());
}}
if (bounds.isValid()) {{
map.fitBounds(bounds, {{ padding: [50, 50] }});
}}
}}
function setZoom(level) {{
map.setZoom(level);
}}
// Initialize QWebChannel and map
document.addEventListener('DOMContentLoaded', function() {{
new QWebChannel(qt.webChannelTransport, function(channel) {{
bridge = channel.objects.bridge;
console.log('QWebChannel connected');
// Initialize map after channel is ready
initMap();
// Notify Python that map is ready
if (bridge) {{
bridge.onMapReady();
}}
}});
}});
</script>
</body>
</html>'''
def _load_map(self):
"""Load the map HTML into the web view"""
html = self._get_map_html()
self._web_view.setHtml(html)
logger.info("Map HTML loaded")
def _on_map_ready(self):
"""Called when the map is fully initialized"""
self._status_label.setText(f"Map ready - {len(self._targets)} targets")
self._status_label.setStyleSheet(f"color: {DARK_SUCCESS};")
logger.info("Map widget ready")
def _on_marker_clicked(self, target_id: int):
"""Handle marker click events"""
self.targetSelected.emit(target_id)
def _on_tile_server_changed(self, index: int):
"""Handle tile server change"""
server = self._tile_combo.currentData()
self._tile_server = server
self._run_js(f"setTileServer('{server.value}')")
def _on_coverage_toggled(self, state: int):
"""Handle coverage visibility toggle"""
visible = state == Qt.CheckState.Checked.value
self._show_coverage = visible
self._run_js(f"setCoverageVisible({str(visible).lower()})")
def _on_trails_toggled(self, state: int):
"""Handle trails visibility toggle"""
visible = state == Qt.CheckState.Checked.value
self._show_trails = visible
self._run_js(f"setTrailsVisible({str(visible).lower()})")
def _center_on_radar(self):
"""Center map view on radar position"""
self._run_js("centerOnRadar()")
def _fit_all_targets(self):
"""Fit map view to show all targets"""
self._run_js("fitAllTargets()")
def _run_js(self, script: str):
"""Execute JavaScript in the web view"""
self._web_view.page().runJavaScript(script)
# Public API
def set_radar_position(self, gps_data: GPSData):
"""Update the radar position on the map"""
self._radar_position = gps_data
self._run_js(
f"updateRadarPosition({gps_data.latitude}, {gps_data.longitude}, "
f"{gps_data.altitude}, {gps_data.pitch}, {gps_data.heading})"
)
def set_targets(self, targets: List[RadarTarget]):
"""Update all targets on the map"""
self._targets = targets
# Convert targets to JSON
targets_data = [t.to_dict() for t in targets]
targets_json = json.dumps(targets_data)
# Update status
self._status_label.setText(f"{len(targets)} targets tracked")
# Call JavaScript update function
self._run_js(f"updateTargets('{targets_json}')")
def set_coverage_radius(self, radius: float):
"""Set the coverage circle radius in meters"""
self._coverage_radius = radius
self._run_js(f"setCoverageRadius({radius})")
def set_zoom(self, level: int):
"""Set map zoom level (0-19)"""
level = max(0, min(19, level))
self._run_js(f"setZoom({level})")
# =============================================================================
# Utility Functions
# =============================================================================
def polar_to_geographic(
radar_lat: float,
radar_lon: float,
range_m: float,
azimuth_deg: float
) -> Tuple[float, float]:
"""
Convert polar coordinates (range, azimuth) relative to radar
to geographic coordinates (latitude, longitude).
Args:
radar_lat: Radar latitude in degrees
radar_lon: Radar longitude in degrees
range_m: Range from radar in meters
azimuth_deg: Azimuth angle in degrees (0 = North, clockwise)
Returns:
Tuple of (latitude, longitude) for the target
"""
# Earth's radius in meters
R = 6371000
# Convert to radians
lat1 = math.radians(radar_lat)
lon1 = math.radians(radar_lon)
bearing = math.radians(azimuth_deg)
# Calculate new position
lat2 = math.asin(
math.sin(lat1) * math.cos(range_m / R) +
math.cos(lat1) * math.sin(range_m / R) * math.cos(bearing)
)
lon2 = lon1 + math.atan2(
math.sin(bearing) * math.sin(range_m / R) * math.cos(lat1),
math.cos(range_m / R) - math.sin(lat1) * math.sin(lat2)
)
return (math.degrees(lat2), math.degrees(lon2))
# =============================================================================
# Target Simulator (Demo Mode)
# =============================================================================
class TargetSimulator(QObject):
"""Simulates radar targets for demonstration purposes"""
targetsUpdated = pyqtSignal(list) # Emits list of RadarTarget
def __init__(self, radar_position: GPSData, parent=None):
super().__init__(parent)
self._radar_position = radar_position
self._targets: List[RadarTarget] = []
self._next_id = 1
self._timer = QTimer()
self._timer.timeout.connect(self._update_targets)
# Initialize some targets
self._initialize_targets()
def _initialize_targets(self, count: int = 8):
"""Create initial set of simulated targets"""
for _ in range(count):
self._add_random_target()
def _add_random_target(self):
"""Add a new random target"""
# Random range between 5km and 40km
range_m = random.uniform(5000, 40000)
# Random azimuth
azimuth = random.uniform(0, 360)
# Random velocity (-100 to +100 m/s)
velocity = random.uniform(-100, 100)
# Random elevation
elevation = random.uniform(-5, 45)
# Calculate geographic position
lat, lon = polar_to_geographic(
self._radar_position.latitude,
self._radar_position.longitude,
range_m,
azimuth
)
target = RadarTarget(
id=self._next_id,
range=range_m,
velocity=velocity,
azimuth=azimuth,
elevation=elevation,
latitude=lat,
longitude=lon,
snr=random.uniform(10, 35),
timestamp=time.time(),
track_id=self._next_id,
classification=random.choice(["aircraft", "drone", "bird", "unknown"])
)
self._next_id += 1
self._targets.append(target)
def _update_targets(self):
"""Update target positions (called by timer)"""
updated_targets = []
for target in self._targets:
# Update range based on velocity
new_range = target.range - target.velocity * 0.5 # 0.5 second update
# Check if target is still in range
if new_range < 500 or new_range > 50000:
# Remove this target and add a new one
continue
# Slightly vary velocity
new_velocity = target.velocity + random.uniform(-2, 2)
new_velocity = max(-150, min(150, new_velocity))
# Slightly vary azimuth (simulate turning)
new_azimuth = (target.azimuth + random.uniform(-0.5, 0.5)) % 360
# Calculate new geographic position
lat, lon = polar_to_geographic(
self._radar_position.latitude,
self._radar_position.longitude,
new_range,
new_azimuth
)
updated_target = RadarTarget(
id=target.id,
range=new_range,
velocity=new_velocity,
azimuth=new_azimuth,
elevation=target.elevation + random.uniform(-0.1, 0.1),
latitude=lat,
longitude=lon,
snr=target.snr + random.uniform(-1, 1),
timestamp=time.time(),
track_id=target.track_id,
classification=target.classification
)
updated_targets.append(updated_target)
# Occasionally add new targets
if len(updated_targets) < 5 or (random.random() < 0.05 and len(updated_targets) < 15):
self._add_random_target()
updated_targets.append(self._targets[-1])
self._targets = updated_targets
self.targetsUpdated.emit(updated_targets)
def start(self, interval_ms: int = 500):
"""Start the simulation"""
self._timer.start(interval_ms)
def stop(self):
"""Stop the simulation"""
self._timer.stop()
def set_radar_position(self, gps_data: GPSData):
"""Update radar position"""
self._radar_position = gps_data
# =============================================================================
# Main Dashboard Window
# =============================================================================
class RadarDashboard(QMainWindow):
"""Main application window for the radar dashboard"""
def __init__(self):
super().__init__()
# State
self._radar_position = GPSData(
latitude=40.7128,
longitude=-74.0060,
altitude=100.0,
pitch=0.0,
heading=0.0,
timestamp=time.time()
)
self._settings = RadarSettings()
self._simulator: Optional[TargetSimulator] = None
self._demo_mode = True
# Setup UI
self._setup_window()
self._setup_dark_theme()
self._setup_ui()
self._setup_statusbar()
# Start demo mode
self._start_demo_mode()
def _setup_window(self):
"""Configure main window properties"""
self.setWindowTitle("PLFM Radar Dashboard - PyQt6 Edition")
self.setMinimumSize(1200, 800)
self.resize(1400, 900)
def _setup_dark_theme(self):
"""Apply dark theme to the application"""
palette = QPalette()
palette.setColor(QPalette.ColorRole.Window, QColor(DARK_BG))
palette.setColor(QPalette.ColorRole.WindowText, QColor(DARK_FG))
palette.setColor(QPalette.ColorRole.Base, QColor(DARK_ACCENT))
palette.setColor(QPalette.ColorRole.AlternateBase, QColor(DARK_HIGHLIGHT))
palette.setColor(QPalette.ColorRole.ToolTipBase, QColor(DARK_ACCENT))
palette.setColor(QPalette.ColorRole.ToolTipText, QColor(DARK_FG))
palette.setColor(QPalette.ColorRole.Text, QColor(DARK_FG))
palette.setColor(QPalette.ColorRole.Button, QColor(DARK_BUTTON))
palette.setColor(QPalette.ColorRole.ButtonText, QColor(DARK_FG))
palette.setColor(QPalette.ColorRole.BrightText, QColor(DARK_FG))
palette.setColor(QPalette.ColorRole.Highlight, QColor(DARK_INFO))
palette.setColor(QPalette.ColorRole.HighlightedText, QColor(DARK_FG))
self.setPalette(palette)
# Global stylesheet
self.setStyleSheet(f"""
QMainWindow {{
background-color: {DARK_BG};
}}
QTabWidget::pane {{
border: 1px solid {DARK_BORDER};
background-color: {DARK_BG};
}}
QTabBar::tab {{
background-color: {DARK_ACCENT};
color: {DARK_FG};
padding: 8px 20px;
margin-right: 2px;
border-top-left-radius: 4px;
border-top-right-radius: 4px;
}}
QTabBar::tab:selected {{
background-color: {DARK_HIGHLIGHT};
border-bottom: 2px solid {DARK_INFO};
}}
QTabBar::tab:hover {{
background-color: {DARK_BUTTON_HOVER};
}}
QGroupBox {{
font-weight: bold;
border: 1px solid {DARK_BORDER};
border-radius: 6px;
margin-top: 12px;
padding-top: 10px;
background-color: {DARK_ACCENT};
}}
QGroupBox::title {{
subcontrol-origin: margin;
left: 10px;
padding: 0 8px;
color: {DARK_INFO};
}}
QPushButton {{
background-color: {DARK_BUTTON};
color: {DARK_FG};
border: 1px solid {DARK_BORDER};
padding: 6px 16px;
border-radius: 4px;
font-weight: 500;
}}
QPushButton:hover {{
background-color: {DARK_BUTTON_HOVER};
}}
QPushButton:pressed {{
background-color: {DARK_HIGHLIGHT};
}}
QLineEdit, QSpinBox, QDoubleSpinBox, QComboBox {{
background-color: {DARK_ACCENT};
color: {DARK_FG};
border: 1px solid {DARK_BORDER};
padding: 4px 8px;
border-radius: 4px;
}}
QLabel {{
color: {DARK_FG};
}}
QTableWidget {{
background-color: {DARK_ACCENT};
color: {DARK_FG};
gridline-color: {DARK_BORDER};
border: 1px solid {DARK_BORDER};
}}
QTableWidget::item {{
padding: 4px;
}}
QTableWidget::item:selected {{
background-color: {DARK_INFO};
}}
QHeaderView::section {{
background-color: {DARK_HIGHLIGHT};
color: {DARK_FG};
padding: 6px;
border: none;
border-right: 1px solid {DARK_BORDER};
}}
QScrollBar:vertical {{
background-color: {DARK_ACCENT};
width: 12px;
margin: 0;
}}
QScrollBar::handle:vertical {{
background-color: {DARK_HIGHLIGHT};
border-radius: 6px;
min-height: 20px;
}}
QStatusBar {{
background-color: {DARK_ACCENT};
color: {DARK_FG};
}}
""")
def _setup_ui(self):
"""Setup the main UI layout"""
# Central widget
central_widget = QWidget()
self.setCentralWidget(central_widget)
main_layout = QVBoxLayout(central_widget)
main_layout.setContentsMargins(8, 8, 8, 8)
main_layout.setSpacing(8)
# Tab widget
self._tabs = QTabWidget()
main_layout.addWidget(self._tabs)
# Create tabs
self._create_map_tab()
self._create_targets_tab()
self._create_settings_tab()
def _create_map_tab(self):
"""Create the map visualization tab"""
tab = QWidget()
layout = QHBoxLayout(tab)
layout.setContentsMargins(4, 4, 4, 4)
# Splitter for map and sidebar
splitter = QSplitter(Qt.Orientation.Horizontal)
# Map widget (main area)
self._map_widget = RadarMapWidget()
self._map_widget.targetSelected.connect(self._on_target_selected)
splitter.addWidget(self._map_widget)
# Sidebar
sidebar = QWidget()
sidebar.setMaximumWidth(320)
sidebar.setMinimumWidth(280)
sidebar_layout = QVBoxLayout(sidebar)
sidebar_layout.setContentsMargins(8, 8, 8, 8)
# Radar Position Group
pos_group = QGroupBox("Radar Position")
pos_layout = QGridLayout(pos_group)
self._lat_spin = QDoubleSpinBox()
self._lat_spin.setRange(-90, 90)
self._lat_spin.setDecimals(6)
self._lat_spin.setValue(self._radar_position.latitude)
self._lat_spin.valueChanged.connect(self._on_position_changed)
self._lon_spin = QDoubleSpinBox()
self._lon_spin.setRange(-180, 180)
self._lon_spin.setDecimals(6)
self._lon_spin.setValue(self._radar_position.longitude)
self._lon_spin.valueChanged.connect(self._on_position_changed)
self._alt_spin = QDoubleSpinBox()
self._alt_spin.setRange(0, 50000)
self._alt_spin.setDecimals(1)
self._alt_spin.setValue(self._radar_position.altitude)
self._alt_spin.setSuffix(" m")
pos_layout.addWidget(QLabel("Latitude:"), 0, 0)
pos_layout.addWidget(self._lat_spin, 0, 1)
pos_layout.addWidget(QLabel("Longitude:"), 1, 0)
pos_layout.addWidget(self._lon_spin, 1, 1)
pos_layout.addWidget(QLabel("Altitude:"), 2, 0)
pos_layout.addWidget(self._alt_spin, 2, 1)
sidebar_layout.addWidget(pos_group)
# Coverage Group
coverage_group = QGroupBox("Coverage")
coverage_layout = QGridLayout(coverage_group)
self._coverage_spin = QDoubleSpinBox()
self._coverage_spin.setRange(1, 100)
self._coverage_spin.setDecimals(1)
self._coverage_spin.setValue(self._settings.coverage_radius / 1000)
self._coverage_spin.setSuffix(" km")
self._coverage_spin.valueChanged.connect(self._on_coverage_changed)
coverage_layout.addWidget(QLabel("Radius:"), 0, 0)
coverage_layout.addWidget(self._coverage_spin, 0, 1)
sidebar_layout.addWidget(coverage_group)
# Demo Controls
demo_group = QGroupBox("Demo Mode")
demo_layout = QVBoxLayout(demo_group)
self._demo_btn = QPushButton("Stop Demo")
self._demo_btn.setCheckable(True)
self._demo_btn.setChecked(True)
self._demo_btn.clicked.connect(self._toggle_demo_mode)
demo_layout.addWidget(self._demo_btn)
add_target_btn = QPushButton("Add Random Target")
add_target_btn.clicked.connect(self._add_demo_target)
demo_layout.addWidget(add_target_btn)
sidebar_layout.addWidget(demo_group)
# Target Info
info_group = QGroupBox("Selected Target")
info_layout = QVBoxLayout(info_group)
self._target_info_label = QLabel("No target selected")
self._target_info_label.setWordWrap(True)
self._target_info_label.setStyleSheet(f"color: {DARK_TEXT}; padding: 8px;")
info_layout.addWidget(self._target_info_label)
sidebar_layout.addWidget(info_group)
sidebar_layout.addStretch()
splitter.addWidget(sidebar)
splitter.setSizes([900, 300])
layout.addWidget(splitter)
self._tabs.addTab(tab, "Map View")
def _create_targets_tab(self):
"""Create the targets table tab"""
tab = QWidget()
layout = QVBoxLayout(tab)
layout.setContentsMargins(8, 8, 8, 8)
# Targets table
self._targets_table = QTableWidget()
self._targets_table.setColumnCount(9)
self._targets_table.setHorizontalHeaderLabels([
"ID", "Track", "Range (m)", "Velocity (m/s)",
"Azimuth (°)", "Elevation (°)", "SNR (dB)",
"Classification", "Status"
])
header = self._targets_table.horizontalHeader()
header.setSectionResizeMode(QHeaderView.ResizeMode.Stretch)
self._targets_table.setSelectionBehavior(
QTableWidget.SelectionBehavior.SelectRows
)
self._targets_table.setAlternatingRowColors(True)
layout.addWidget(self._targets_table)
self._tabs.addTab(tab, "Targets")
def _create_settings_tab(self):
"""Create the settings tab"""
tab = QWidget()
layout = QVBoxLayout(tab)
layout.setContentsMargins(8, 8, 8, 8)
# Radar Settings Group
radar_group = QGroupBox("Radar Parameters")
radar_layout = QGridLayout(radar_group)
radar_layout.addWidget(QLabel("System Frequency:"), 0, 0)
freq_spin = QDoubleSpinBox()
freq_spin.setRange(1, 100)
freq_spin.setValue(self._settings.system_frequency / 1e9)
freq_spin.setSuffix(" GHz")
radar_layout.addWidget(freq_spin, 0, 1)
radar_layout.addWidget(QLabel("Max Range:"), 1, 0)
range_spin = QDoubleSpinBox()
range_spin.setRange(1, 200)
range_spin.setValue(self._settings.max_distance / 1000)
range_spin.setSuffix(" km")
radar_layout.addWidget(range_spin, 1, 1)
radar_layout.addWidget(QLabel("PRF 1:"), 2, 0)
prf1_spin = QSpinBox()
prf1_spin.setRange(100, 10000)
prf1_spin.setValue(int(self._settings.prf1))
prf1_spin.setSuffix(" Hz")
radar_layout.addWidget(prf1_spin, 2, 1)
radar_layout.addWidget(QLabel("PRF 2:"), 3, 0)
prf2_spin = QSpinBox()
prf2_spin.setRange(100, 10000)
prf2_spin.setValue(int(self._settings.prf2))
prf2_spin.setSuffix(" Hz")
radar_layout.addWidget(prf2_spin, 3, 1)
layout.addWidget(radar_group)
# About
about_group = QGroupBox("About")
about_layout = QVBoxLayout(about_group)
about_text = QLabel(
"<b>PLFM Radar Dashboard</b><br>"
"PyQt6 Edition with Embedded Leaflet Map<br><br>"
"Version: 1.0.0<br>"
"Map: OpenStreetMap + Leaflet.js<br>"
"Framework: PyQt6 + QWebEngine"
)
about_text.setStyleSheet(f"color: {DARK_TEXT}; padding: 12px;")
about_layout.addWidget(about_text)
layout.addWidget(about_group)
layout.addStretch()
self._tabs.addTab(tab, "Settings")
def _setup_statusbar(self):
"""Setup the status bar"""
self._statusbar = QStatusBar()
self.setStatusBar(self._statusbar)
self._status_label = QLabel("Ready")
self._statusbar.addWidget(self._status_label)
self._target_count_label = QLabel("Targets: 0")
self._statusbar.addPermanentWidget(self._target_count_label)
self._mode_label = QLabel("Demo Mode")
self._mode_label.setStyleSheet(f"color: {DARK_INFO}; font-weight: bold;")
self._statusbar.addPermanentWidget(self._mode_label)
def _start_demo_mode(self):
"""Start the demo mode with simulated targets"""
self._simulator = TargetSimulator(self._radar_position, self)
self._simulator.targetsUpdated.connect(self._on_targets_updated)
self._simulator.start(500) # Update every 500ms
self._demo_mode = True
self._demo_btn.setChecked(True)
self._demo_btn.setText("Stop Demo")
self._mode_label.setText("Demo Mode")
self._status_label.setText("Demo mode active")
logger.info("Demo mode started")
def _toggle_demo_mode(self, checked: bool):
"""Toggle demo mode on/off"""
if checked:
self._start_demo_mode()
else:
if self._simulator:
self._simulator.stop()
self._demo_mode = False
self._demo_btn.setText("Start Demo")
self._mode_label.setText("Idle")
self._status_label.setText("Demo mode stopped")
logger.info("Demo mode stopped")
def _add_demo_target(self):
"""Add a random target in demo mode"""
if self._simulator:
self._simulator._add_random_target()
logger.info("Added random target")
def _on_targets_updated(self, targets: List[RadarTarget]):
"""Handle updated target list from simulator"""
# Update map
self._map_widget.set_targets(targets)
# Update status bar
self._target_count_label.setText(f"Targets: {len(targets)}")
# Update table
self._update_targets_table(targets)
def _update_targets_table(self, targets: List[RadarTarget]):
"""Update the targets table"""
self._targets_table.setRowCount(len(targets))
for row, target in enumerate(targets):
# ID
self._targets_table.setItem(row, 0, QTableWidgetItem(str(target.id)))
# Track ID
self._targets_table.setItem(row, 1, QTableWidgetItem(str(target.track_id)))
# Range
self._targets_table.setItem(row, 2, QTableWidgetItem(f"{target.range:.1f}"))
# Velocity
vel_item = QTableWidgetItem(f"{target.velocity:+.1f}")
if target.velocity > 1:
vel_item.setForeground(QColor(DARK_ERROR))
elif target.velocity < -1:
vel_item.setForeground(QColor(DARK_INFO))
self._targets_table.setItem(row, 3, vel_item)
# Azimuth
self._targets_table.setItem(row, 4, QTableWidgetItem(f"{target.azimuth:.1f}"))
# Elevation
self._targets_table.setItem(row, 5, QTableWidgetItem(f"{target.elevation:.1f}"))
# SNR
self._targets_table.setItem(row, 6, QTableWidgetItem(f"{target.snr:.1f}"))
# Classification
self._targets_table.setItem(row, 7, QTableWidgetItem(target.classification))
# Status
status = "Approaching" if target.velocity > 1 else (
"Receding" if target.velocity < -1 else "Stationary"
)
status_item = QTableWidgetItem(status)
if status == "Approaching":
status_item.setForeground(QColor(DARK_ERROR))
elif status == "Receding":
status_item.setForeground(QColor(DARK_INFO))
self._targets_table.setItem(row, 8, status_item)
def _on_target_selected(self, target_id: int):
"""Handle target selection from map"""
# Find target
if self._simulator:
for target in self._simulator._targets:
if target.id == target_id:
self._show_target_info(target)
break
def _show_target_info(self, target: RadarTarget):
"""Display target information in sidebar"""
status = "Approaching" if target.velocity > 1 else (
"Receding" if target.velocity < -1 else "Stationary"
)
info = f"""
<b>Target #{target.id}</b><br><br>
<b>Track ID:</b> {target.track_id}<br>
<b>Range:</b> {target.range:.1f} m<br>
<b>Velocity:</b> {target.velocity:+.1f} m/s<br>
<b>Azimuth:</b> {target.azimuth:.1f}°<br>
<b>Elevation:</b> {target.elevation:.1f}°<br>
<b>SNR:</b> {target.snr:.1f} dB<br>
<b>Classification:</b> {target.classification}<br>
<b>Status:</b> <span style="color: {
DARK_ERROR if status == 'Approaching' else
(DARK_INFO if status == 'Receding' else DARK_TEXT)
}">{status}</span>
"""
self._target_info_label.setText(info)
def _on_position_changed(self):
"""Handle radar position change from UI"""
self._radar_position.latitude = self._lat_spin.value()
self._radar_position.longitude = self._lon_spin.value()
self._radar_position.altitude = self._alt_spin.value()
self._map_widget.set_radar_position(self._radar_position)
if self._simulator:
self._simulator.set_radar_position(self._radar_position)
def _on_coverage_changed(self, value: float):
"""Handle coverage radius change"""
radius_m = value * 1000
self._settings.coverage_radius = radius_m
self._map_widget.set_coverage_radius(radius_m)
def closeEvent(self, event):
"""Handle window close"""
if self._simulator:
self._simulator.stop()
event.accept()
# =============================================================================
# Main Entry Point
# =============================================================================
def main():
"""Application entry point"""
# Create application
app = QApplication(sys.argv)
app.setApplicationName("PLFM Radar Dashboard")
app.setApplicationVersion("1.0.0")
# Set font
font = QFont("Segoe UI", 10)
app.setFont(font)
# Create and show main window
window = RadarDashboard()
window.show()
logger.info("Application started")
# Run event loop
sys.exit(app.exec())
if __name__ == "__main__":
main()