""" v7.map_widget — Embedded Leaflet.js map widget for the PLFM Radar GUI V7. Classes: - MapBridge — QObject exposed to JavaScript via QWebChannel - RadarMapWidget — QWidget wrapping QWebEngineView with Leaflet map The full HTML/CSS/JS for Leaflet is generated inline (no external files). Supports: OSM, Google, Google Sat, Google Hybrid, ESRI Sat tile servers; coverage circle, target trails, velocity-based color coding, popups, legend. """ import json import logging from PyQt6.QtWidgets import ( QWidget, QVBoxLayout, QHBoxLayout, QFrame, QComboBox, QCheckBox, QPushButton, QLabel, ) from PyQt6.QtCore import Qt, QUrl, pyqtSignal, pyqtSlot, QObject from PyQt6.QtWebEngineCore import QWebEngineSettings from PyQt6.QtWebEngineWidgets import QWebEngineView from PyQt6.QtWebChannel import QWebChannel from .models import ( GPSData, RadarTarget, TileServer, DARK_BG, DARK_FG, DARK_ACCENT, DARK_BORDER, DARK_TEXT, DARK_BUTTON, DARK_BUTTON_HOVER, DARK_SUCCESS, DARK_INFO, ) logger = logging.getLogger(__name__) # ============================================================================= # MapBridge — Python <-> JavaScript # ============================================================================= class MapBridge(QObject): """Bridge object registered with QWebChannel for JS ↔ Python calls.""" 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): logger.debug(f"Map clicked: {lat}, {lon}") self.mapClicked.emit(lat, lon) @pyqtSlot(int) def onMarkerClick(self, target_id: int): logger.debug(f"Marker clicked: #{target_id}") self.markerClicked.emit(target_id) @pyqtSlot() def onMapReady(self): logger.info("Leaflet map ready") self._map_ready = True self.mapReady.emit() @pyqtSlot(str) def logFromJS(self, message: str): logger.info(f"[JS] {message}") @property def is_ready(self) -> bool: return self._map_ready # ============================================================================= # RadarMapWidget # ============================================================================= class RadarMapWidget(QWidget): """ Embeds a Leaflet.js interactive map inside a QWebEngineView. Public methods mirror the V6 map API: set_radar_position(gps), set_targets(list), set_coverage_radius(r), set_zoom(level) """ targetSelected = pyqtSignal(int) def __init__(self, radar_lat: float = 41.9028, radar_lon: float = 12.4964, parent=None): super().__init__(parent) # State self._radar_position = GPSData( latitude=radar_lat, longitude=radar_lon, altitude=0.0, pitch=0.0, heading=0.0, ) self._targets: list[RadarTarget] = [] self._pending_targets: list[RadarTarget] | None = None self._coverage_radius = 1_536 # metres (64 bins x 24 m, 3 km mode) self._tile_server = TileServer.OPENSTREETMAP self._show_coverage = True self._show_trails = False # Build UI self._setup_ui() # Bridge + channel self._bridge = MapBridge(self) self._bridge.mapReady.connect(self._on_map_ready) self._bridge.markerClicked.connect(self._on_marker_clicked) self._channel = QWebChannel() self._channel.registerObject("bridge", self._bridge) self._web_view.page().setWebChannel(self._channel) # Load the Leaflet map self._load_map() # ---- UI setup ---------------------------------------------------------- def _setup_ui(self): layout = QVBoxLayout(self) layout.setContentsMargins(0, 0, 0, 0) # Control bar bar = QFrame() bar.setStyleSheet(f"background-color: {DARK_ACCENT}; border-radius: 4px;") bar_layout = QHBoxLayout(bar) bar_layout.setContentsMargins(8, 4, 8, 4) # Tile 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_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; }} """) bar_layout.addWidget(QLabel("Tiles:")) bar_layout.addWidget(self._tile_combo) # Toggles self._coverage_check = QCheckBox("Coverage") self._coverage_check.setChecked(True) self._coverage_check.stateChanged.connect(self._on_coverage_toggled) bar_layout.addWidget(self._coverage_check) self._trails_check = QCheckBox("Trails") self._trails_check.setChecked(False) self._trails_check.stateChanged.connect(self._on_trails_toggled) bar_layout.addWidget(self._trails_check) btn_style = 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}; }} """ center_btn = QPushButton("Center") center_btn.clicked.connect(self._center_on_radar) center_btn.setStyleSheet(btn_style) bar_layout.addWidget(center_btn) fit_btn = QPushButton("Fit All") fit_btn.clicked.connect(self._fit_all) fit_btn.setStyleSheet(btn_style) bar_layout.addWidget(fit_btn) bar_layout.addStretch() self._status_label = QLabel("Loading map...") self._status_label.setStyleSheet(f"color: {DARK_INFO};") bar_layout.addWidget(self._status_label) layout.addWidget(bar) # Web view self._web_view = QWebEngineView() self._web_view.setMinimumSize(400, 300) layout.addWidget(self._web_view, stretch=1) # ---- HTML generation --------------------------------------------------- def _get_map_html(self) -> str: lat = self._radar_position.latitude lon = self._radar_position.longitude cov = self._coverage_radius # Using {{ / }} for literal braces inside the f-string return f'''