2106e24952
- Expand ruff config from E/F to 17 rule sets (B, RUF, SIM, PIE, T20, ARG, ERA, A, BLE, RET, ISC, TCH, UP, C4, PERF) - Fix 907 lint errors across all Python files (GUI, FPGA cosim, schematics scripts, simulations, utilities, tools) - Replace all blind except-Exception with specific exception types - Remove commented-out dead code (ERA001) from cosim/simulation files - Modernize typing: deprecated typing.List/Dict/Tuple to builtins - Fix unused args/loop vars, ambiguous unicode, perf anti-patterns - Delete legacy GUI files V1-V4 - Add V7 test suite, requirements files - All CI jobs pass: ruff (0 errors), py_compile, pytest (92/92), MCU tests (20/20), FPGA regression (25/25)
1711 lines
60 KiB
Python
1711 lines
60 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 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: '© <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: '© 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: '© 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: '© Google Maps',
|
|
maxZoom: 22
|
|
}},
|
|
'esri_sat': {{
|
|
url: 'https://server.arcgisonline.com/ArcGIS/rest/services/World_Imagery/MapServer/tile/{{z}}/{{y}}/{{x}}',
|
|
attribution: '© 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) + '°</span></div>' +
|
|
(
|
|
'<div class="popup-row"><span class="popup-label">Elevation:</span>' +
|
|
'<span class="popup-value">'
|
|
) +
|
|
target.elevation.toFixed(1) + '°</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: TargetSimulator | None = 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()
|