#!/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 = 10.5e9 # Hz (PLFM TX LO)
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 = 1536 # Max detection range (m) -- 64 bins x 24 m
coverage_radius: float = 1536 # 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 = 1536 # meters (64 bins x 24 m, 3 km mode)
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'''
Radar Map
'''
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 < 50 or new_range > 1536:
# 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(
"PLFM Radar Dashboard
"
"PyQt6 Edition with Embedded Leaflet Map
"
"Version: 1.0.0
"
"Map: OpenStreetMap + Leaflet.js
"
"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"""
Target #{target.id}
Track ID: {target.track_id}
Range: {target.range:.1f} m
Velocity: {target.velocity:+.1f} m/s
Azimuth: {target.azimuth:.1f}°
Elevation: {target.elevation:.1f}°
SNR: {target.snr:.1f} dB
Classification: {target.classification}
Status: {status}
"""
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()