a16472480a
- workers.py: Only emit playbackStateChanged on state transitions to prevent stale 'playing' signal from overwriting pause button text - dashboard.py: Force C locale on all QDoubleSpinBox instances so comma-decimal locales don't break numeric input; add missing 'Saturation' legend label to AGC chart - map_widget.py: Enable LocalContentCanAccessRemoteUrls and set HTTP base URL so Leaflet CDN tiles/scripts load correctly in QtWebEngine
608 lines
22 KiB
Python
608 lines
22 KiB
Python
"""
|
|
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 = 50_000 # metres
|
|
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'''<!DOCTYPE html>
|
|
<html>
|
|
<head>
|
|
<meta charset="utf-8">
|
|
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
|
<title>Radar Map</title>
|
|
<link rel="stylesheet" href="https://unpkg.com/leaflet@1.9.4/dist/leaflet.css"
|
|
integrity="sha256-p4NxAoJBhIIN+hmNHrzRCf9tD/miZyoHS5obTRR9BMY=" crossorigin=""/>
|
|
<script src="https://unpkg.com/leaflet@1.9.4/dist/leaflet.js"
|
|
integrity="sha256-20nQCchB9co0qIjJZRGuk2/Z9VM+kNiyxNV1lvTlZBo=" crossorigin=""></script>
|
|
<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; }}
|
|
.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 {{
|
|
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>
|
|
var map, radarMarker, coverageCircle;
|
|
var targetMarkers = {{}};
|
|
var targetTrails = {{}};
|
|
var targetTrailHistory = {{}};
|
|
var bridge = null;
|
|
var currentTileLayer = null;
|
|
var showCoverage = true;
|
|
var showTrails = false;
|
|
var maxTrailLength = 30;
|
|
|
|
var tileServers = {{
|
|
'osm': {{
|
|
url:'https://{{s}}.tile.openstreetmap.org/{{z}}/{{x}}/{{y}}.png',
|
|
attribution:'© OpenStreetMap', 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
|
|
}}
|
|
}};
|
|
|
|
function initMap() {{
|
|
map = L.map('map', {{ preferCanvas:true, zoomControl:true }})
|
|
.setView([{lat}, {lon}], 10);
|
|
setTileServer('osm');
|
|
|
|
radarMarker = L.circleMarker([{lat},{lon}], {{
|
|
radius:12, fillColor:'#FF5252', color:'white',
|
|
weight:3, opacity:1, fillOpacity:1
|
|
}}).addTo(map);
|
|
updateRadarPopup();
|
|
|
|
coverageCircle = L.circle([{lat},{lon}], {{
|
|
radius:{cov}, color:'#FF5252', fillColor:'#FF5252',
|
|
fillOpacity:0.08, weight:2, dashArray:'8, 8'
|
|
}}).addTo(map);
|
|
|
|
addLegend();
|
|
map.on('click', function(e){{ if(bridge) bridge.onMapClick(e.latlng.lat,e.latlng.lng); }});
|
|
}}
|
|
|
|
function setTileServer(id) {{
|
|
var cfg = tileServers[id]; if(!cfg) return;
|
|
if(currentTileLayer) map.removeLayer(currentTileLayer);
|
|
currentTileLayer = L.tileLayer(
|
|
cfg.url,
|
|
{{ attribution:cfg.attribution, maxZoom:cfg.maxZoom }}
|
|
).addTo(map);
|
|
}}
|
|
|
|
function updateRadarPopup() {{
|
|
if(!radarMarker) return;
|
|
var ll = radarMarker.getLatLng();
|
|
radarMarker.bindPopup(
|
|
'<div class="popup-title">Radar System</div>'+
|
|
(
|
|
'<div class="popup-row"><span class="popup-label">Lat:</span>'+
|
|
'<span class="popup-value">'+ll.lat.toFixed(6)+'</span></div>'
|
|
)+
|
|
(
|
|
'<div class="popup-row"><span class="popup-label">Lon:</span>'+
|
|
'<span class="popup-value">'+ll.lng.toFixed(6)+'</span></div>'
|
|
)+
|
|
(
|
|
'<div class="popup-row"><span class="popup-label">Status:</span>'+
|
|
'<span class="popup-value status-approaching">Active</span></div>'
|
|
)
|
|
);
|
|
}}
|
|
|
|
function addLegend() {{
|
|
var legend = L.control({{ position:'bottomright' }});
|
|
legend.onAdd = function() {{
|
|
var d = L.DomUtil.create('div','legend');
|
|
d.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 d;
|
|
}};
|
|
legend.addTo(map);
|
|
}}
|
|
|
|
function updateRadarPosition(lat,lon,alt,pitch,heading) {{
|
|
if(!radarMarker||!coverageCircle) return;
|
|
radarMarker.setLatLng([lat,lon]);
|
|
coverageCircle.setLatLng([lat,lon]);
|
|
updateRadarPopup();
|
|
}}
|
|
|
|
function updateTargets(targetsJson) {{
|
|
try {{
|
|
if(!map) {{
|
|
if(bridge) bridge.logFromJS('updateTargets: map not ready yet');
|
|
return;
|
|
}}
|
|
var targets = JSON.parse(targetsJson);
|
|
if(bridge) bridge.logFromJS('updateTargets: parsed '+targets.length+' targets');
|
|
var currentIds = {{}};
|
|
|
|
targets.forEach(function(t) {{
|
|
currentIds[t.id] = true;
|
|
var lat=t.latitude, lon=t.longitude;
|
|
var color = getTargetColor(t.velocity);
|
|
var radius = Math.max(5, Math.min(12, 5+(t.snr||0)/5));
|
|
|
|
if(!targetTrailHistory[t.id]) targetTrailHistory[t.id] = [];
|
|
targetTrailHistory[t.id].push([lat,lon]);
|
|
if(targetTrailHistory[t.id].length > maxTrailLength)
|
|
targetTrailHistory[t.id].shift();
|
|
|
|
if(targetMarkers[t.id]) {{
|
|
targetMarkers[t.id].setLatLng([lat,lon]);
|
|
targetMarkers[t.id].setStyle({{
|
|
fillColor:color, color:'white', radius:radius
|
|
}});
|
|
if(targetTrails[t.id]) {{
|
|
targetTrails[t.id].setLatLngs(targetTrailHistory[t.id]);
|
|
targetTrails[t.id].setStyle({{ color:color }});
|
|
}}
|
|
}} else {{
|
|
var marker = L.circleMarker([lat,lon], {{
|
|
radius:radius, fillColor:color, color:'white',
|
|
weight:2, opacity:1, fillOpacity:0.9
|
|
}}).addTo(map);
|
|
marker.on(
|
|
'click',
|
|
(function(id){{
|
|
return function(){{ if(bridge) bridge.onMarkerClick(id); }};
|
|
}})(t.id)
|
|
);
|
|
targetMarkers[t.id] = marker;
|
|
if(showTrails) {{
|
|
targetTrails[t.id] = L.polyline(targetTrailHistory[t.id], {{
|
|
color:color, weight:3, opacity:0.7,
|
|
lineCap:'round', lineJoin:'round'
|
|
}}).addTo(map);
|
|
}}
|
|
}}
|
|
updateTargetPopup(t);
|
|
}});
|
|
|
|
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];
|
|
}}
|
|
delete targetTrailHistory[id];
|
|
}}
|
|
}}
|
|
}} catch(e) {{
|
|
if(bridge) bridge.logFromJS('updateTargets ERROR: '+e.message);
|
|
}}
|
|
}}
|
|
|
|
function updateTargetPopup(t) {{
|
|
if(!targetMarkers[t.id]) return;
|
|
var sc = t.velocity>1
|
|
? 'status-approaching'
|
|
: (t.velocity<-1 ? 'status-receding' : 'status-stationary');
|
|
var st = t.velocity>1?'Approaching':(t.velocity<-1?'Receding':'Stationary');
|
|
var rng = (typeof t.range === 'number') ? t.range.toFixed(1) : '?';
|
|
var vel = (typeof t.velocity === 'number') ? t.velocity.toFixed(1) : '?';
|
|
var az = (typeof t.azimuth === 'number') ? t.azimuth.toFixed(1) : '?';
|
|
var el = (typeof t.elevation === 'number') ? t.elevation.toFixed(1) : '?';
|
|
var snr = (typeof t.snr === 'number') ? t.snr.toFixed(1) : '?';
|
|
targetMarkers[t.id].bindPopup(
|
|
'<div class="popup-title">Target #'+t.id+'</div>'+
|
|
'<div class="popup-row"><span class="popup-label">Range:</span>'+
|
|
'<span class="popup-value">'+rng+' m</span></div>'+
|
|
'<div class="popup-row"><span class="popup-label">Velocity:</span>'+
|
|
'<span class="popup-value">'+vel+' m/s</span></div>'+
|
|
'<div class="popup-row"><span class="popup-label">Azimuth:</span>'+
|
|
'<span class="popup-value">'+az+'°</span></div>'+
|
|
'<div class="popup-row"><span class="popup-label">Elevation:</span>'+
|
|
'<span class="popup-value">'+el+'°</span></div>'+
|
|
'<div class="popup-row"><span class="popup-label">SNR:</span>'+
|
|
'<span class="popup-value">'+snr+' dB</span></div>'+
|
|
'<div class="popup-row"><span class="popup-label">Track:</span>'+
|
|
'<span class="popup-value">'+t.track_id+'</span></div>'+
|
|
'<div class="popup-row"><span class="popup-label">Status:</span>'+
|
|
'<span class="popup-value '+sc+'">'+st+'</span></div>'
|
|
);
|
|
}}
|
|
|
|
function getTargetColor(v) {{
|
|
if(v>50) return '#FF1744';
|
|
if(v>10) return '#FF5252';
|
|
if(v>1) return '#FF8A65';
|
|
if(v<-50) return '#1565C0';
|
|
if(v<-10) return '#2196F3';
|
|
if(v<-1) return '#64B5F6';
|
|
return '#9E9E9E';
|
|
}}
|
|
|
|
function setCoverageVisible(vis) {{
|
|
showCoverage = vis;
|
|
if(coverageCircle) {{
|
|
if(vis) coverageCircle.addTo(map); else map.removeLayer(coverageCircle);
|
|
}}
|
|
}}
|
|
function setCoverageRadius(r) {{ if(coverageCircle) coverageCircle.setRadius(r); }}
|
|
|
|
function setTrailsVisible(vis) {{
|
|
showTrails = vis;
|
|
if(vis) {{
|
|
for(var id in targetMarkers) {{
|
|
if(!targetTrails[id] && targetTrailHistory[id] && targetTrailHistory[id].length>1) {{
|
|
targetTrails[id] = L.polyline(targetTrailHistory[id], {{
|
|
color:'#4CAF50', weight:3, opacity:0.7, lineCap:'round', lineJoin:'round'
|
|
}}).addTo(map);
|
|
}} else if(targetTrails[id]) {{
|
|
targetTrails[id].addTo(map);
|
|
}}
|
|
}}
|
|
}} else {{
|
|
for(var id in targetTrails) {{ map.removeLayer(targetTrails[id]); }}
|
|
}}
|
|
}}
|
|
|
|
function centerOnRadar() {{ if(radarMarker) map.setView(radarMarker.getLatLng(), map.getZoom()); }}
|
|
|
|
function fitAllTargets() {{
|
|
var b = L.latLngBounds([]);
|
|
if(radarMarker) b.extend(radarMarker.getLatLng());
|
|
for(var id in targetMarkers) b.extend(targetMarkers[id].getLatLng());
|
|
if(b.isValid()) map.fitBounds(b, {{ padding:[50,50] }});
|
|
}}
|
|
|
|
function setZoom(lvl) {{ map.setZoom(lvl); }}
|
|
|
|
document.addEventListener('DOMContentLoaded', function() {{
|
|
new QWebChannel(qt.webChannelTransport, function(ch) {{
|
|
bridge = ch.objects.bridge;
|
|
initMap();
|
|
if(bridge) bridge.onMapReady();
|
|
}});
|
|
}});
|
|
</script>
|
|
</body>
|
|
</html>'''
|
|
|
|
# ---- load / helpers ----------------------------------------------------
|
|
|
|
def _load_map(self):
|
|
# Enable remote resource access so Leaflet CDN scripts/tiles can load.
|
|
settings = self._web_view.page().settings()
|
|
settings.setAttribute(
|
|
QWebEngineSettings.WebAttribute.LocalContentCanAccessRemoteUrls,
|
|
True,
|
|
)
|
|
# Provide an HTTP base URL so the page has a proper origin;
|
|
# without this, setHtml() defaults to about:blank which blocks
|
|
# external resource loading in modern Chromium.
|
|
self._web_view.setHtml(
|
|
self._get_map_html(),
|
|
QUrl("http://localhost/radar_map"),
|
|
)
|
|
logger.info("Leaflet map HTML loaded (with HTTP base URL)")
|
|
|
|
def _on_map_ready(self):
|
|
self._status_label.setText(f"Map ready - {len(self._targets)} targets")
|
|
self._status_label.setStyleSheet(f"color: {DARK_SUCCESS};")
|
|
# Flush any targets that arrived before the map was ready
|
|
if self._pending_targets is not None:
|
|
self.set_targets(self._pending_targets)
|
|
self._pending_targets = None
|
|
|
|
def _on_marker_clicked(self, tid: int):
|
|
self.targetSelected.emit(tid)
|
|
|
|
def _run_js(self, script: str):
|
|
def _js_callback(result):
|
|
if result is not None:
|
|
logger.info("JS result: %s", result)
|
|
self._web_view.page().runJavaScript(script, 0, _js_callback)
|
|
|
|
# ---- control bar callbacks ---------------------------------------------
|
|
|
|
def _on_tile_changed(self, _index: int):
|
|
server = self._tile_combo.currentData()
|
|
if server:
|
|
self._tile_server = server
|
|
self._run_js(f"setTileServer('{server.value}')")
|
|
|
|
def _on_coverage_toggled(self, state: int):
|
|
vis = state == Qt.CheckState.Checked.value
|
|
self._show_coverage = vis
|
|
self._run_js(f"setCoverageVisible({str(vis).lower()})")
|
|
|
|
def _on_trails_toggled(self, state: int):
|
|
vis = state == Qt.CheckState.Checked.value
|
|
self._show_trails = vis
|
|
self._run_js(f"setTrailsVisible({str(vis).lower()})")
|
|
|
|
def _center_on_radar(self):
|
|
self._run_js("centerOnRadar()")
|
|
|
|
def _fit_all(self):
|
|
self._run_js("fitAllTargets()")
|
|
|
|
# ---- public API --------------------------------------------------------
|
|
|
|
def set_radar_position(self, gps: GPSData):
|
|
self._radar_position = gps
|
|
self._run_js(
|
|
f"updateRadarPosition({gps.latitude},{gps.longitude},"
|
|
f"{gps.altitude},{gps.pitch},{gps.heading})"
|
|
)
|
|
|
|
def set_targets(self, targets: list[RadarTarget]):
|
|
self._targets = targets
|
|
if not self._bridge.is_ready:
|
|
logger.info("Map not ready yet — queuing %d targets", len(targets))
|
|
self._pending_targets = targets
|
|
return
|
|
data = [t.to_dict() for t in targets]
|
|
js_payload = json.dumps(data).replace("\\", "\\\\").replace("'", "\\'")
|
|
logger.info(
|
|
"set_targets: %d targets, JSON len=%d, first 200 chars: %s",
|
|
len(targets), len(js_payload), js_payload[:200],
|
|
)
|
|
self._status_label.setText(f"{len(targets)} targets tracked")
|
|
self._run_js(f"updateTargets('{js_payload}')")
|
|
|
|
def set_coverage_radius(self, radius_m: float):
|
|
self._coverage_radius = radius_m
|
|
self._run_js(f"setCoverageRadius({radius_m})")
|
|
|
|
def set_zoom(self, level: int):
|
|
level = max(0, min(22, level))
|
|
self._run_js(f"setZoom({level})")
|