diff --git a/htdocs/gfx/icon-KiwiSDR.png b/htdocs/gfx/icon-KiwiSDR.png new file mode 100644 index 00000000..c3fcaaa9 Binary files /dev/null and b/htdocs/gfx/icon-KiwiSDR.png differ diff --git a/htdocs/gfx/icon-OpenWebRX.png b/htdocs/gfx/icon-OpenWebRX.png new file mode 100644 index 00000000..ad42441f Binary files /dev/null and b/htdocs/gfx/icon-OpenWebRX.png differ diff --git a/htdocs/gfx/icon-Stations.png b/htdocs/gfx/icon-Stations.png new file mode 100644 index 00000000..0b6bb8f5 Binary files /dev/null and b/htdocs/gfx/icon-Stations.png differ diff --git a/htdocs/gfx/icon-WebSDR.png b/htdocs/gfx/icon-WebSDR.png new file mode 100644 index 00000000..783cc0ac Binary files /dev/null and b/htdocs/gfx/icon-WebSDR.png differ diff --git a/htdocs/lib/Leaflet.js b/htdocs/lib/Leaflet.js new file mode 100644 index 00000000..51c353fb --- /dev/null +++ b/htdocs/lib/Leaflet.js @@ -0,0 +1,104 @@ +// +// Leaflet-Specific Marker +// + +function LMarker () { + this._marker = L.marker(); +}; + +LMarker.prototype.setMarkerOptions = function(options) { + $.extend(this, options); + if (typeof this.draw !== 'undefined') { + this.draw(); + } +}; + +LMarker.prototype.setMap = function (map) { + if (map) this._marker.addTo(map); + else this._marker.remove(); +}; + +LMarker.prototype.addListener = function (e, f) { + this._marker.on(e, f); +}; + +LMarker.prototype.getPos = function () { + return [this.position.lat(), this.position.lng()]; +}; + +LMarker.prototype.setMarkerOpacity = function(opacity) { + this._marker.setOpacity(opacity); +}; + +LMarker.prototype.setLatLng = function(lat, lon) { + this._marker.setLatLng([lat, lon]); + this.position = new posObj([lat, lon]); +}; + +LMarker.prototype.setTitle = function(title) { + this._marker.options.title = title; +}; + +LMarker.prototype.setIcon = function(opts) { + this._marker.setIcon(opts); +}; + +LMarker.prototype.setMarkerPosition = function(title, lat, lon) { + this.setLatLng(lat, lon); + this.setTitle(title); +}; + +// Leaflet-Specific FeatureMarker +function LFeatureMarker() { $.extend(this, new LMarker(), new FeatureMarker()); } + +// Leaflet-Specific AprsMarker +function LAprsMarker () { $.extend(this, new LMarker(), new AprsMarker()); } + +// +// Leaflet-Specific Locator +// + +function LLocator() { + this._rect = L.rectangle([[0,0], [1,1]], { color: '#FFFFFF', weight: 2, opacity: 1 }); +} + +LLocator.prototype = new Locator(); + +LLocator.prototype.setMap = function(map) { + if (map) this._rect.addTo(map); + else this._rect.remove(); +}; + +LLocator.prototype.setCenter = function(lat, lon) { + this.center = [lat, lon]; + this._rect.setBounds([[lat - 0.5, lon - 1], [lat + 0.5, lon + 1]]); +} + +LLocator.prototype.setColor = function(color) { + this._rect.setStyle({ color }); +}; + +LLocator.prototype.setOpacity = function(opacity) { + this._rect.setStyle({ + opacity : LocatorManager.strokeOpacity * opacity, + fillOpacity : LocatorManager.fillOpacity * opacity + }); +}; + +LLocator.prototype.addListener = function (e, f) { + this._rect.on(e, f); +}; + + +// Position object +function posObj(pos) { + if (typeof pos === 'undefined' || typeof pos[1] === 'undefined') { + console.error('Cannot create position object with no LatLng.'); + return; + } + this._lat = pos[0]; + this._lng = pos[1]; +} +posObj.prototype.lat = function () { return this._lat; } +posObj.prototype.lng = function () { return this._lng; } + diff --git a/htdocs/lib/MapManager.js b/htdocs/lib/MapManager.js index 79c658ea..9ef84025 100644 --- a/htdocs/lib/MapManager.js +++ b/htdocs/lib/MapManager.js @@ -72,7 +72,9 @@ MapManager.prototype.process = function(e) { break; case 'receiver_details': - $('.webrx-top-container').header().setDetails(json.value); + $().ready(function () { // make sure header is loaded + $('.webrx-top-container').header().setDetails(json.value); + }); break; case "config": diff --git a/htdocs/lib/MapMarkers.js b/htdocs/lib/MapMarkers.js index e51c279a..f28da17e 100644 --- a/htdocs/lib/MapMarkers.js +++ b/htdocs/lib/MapMarkers.js @@ -254,10 +254,13 @@ FeatureMarker.prototype.draw = function() { div.style.color = this.color? this.color : '#000000'; div.innerHTML = this.symbol? this.symbol : '●'; - var point = this.getProjection().fromLatLngToDivPixel(this.position); - if (point) { - div.style.left = point.x - this.symWidth/2 + 'px'; - div.style.top = point.y - this.symHeight/2 + 'px'; + // AF: this is of GMaps + if (typeof this.getProjection !== 'undefined') { + var point = this.getProjection().fromLatLngToDivPixel(this.position); + if (point) { + div.style.left = point.x - this.symWidth / 2 + 'px'; + div.style.top = point.y - this.symHeight / 2 + 'px'; + } } }; @@ -410,10 +413,13 @@ AprsMarker.prototype.draw = function() { div.style.opacity = null; } - var point = this.getProjection().fromLatLngToDivPixel(this.position); - if (point) { - div.style.left = point.x - 12 + 'px'; - div.style.top = point.y - 12 + 'px'; + // AF: this is for GMaps + if (typeof this.getProjection !== 'undefined') { + var point = this.getProjection().fromLatLngToDivPixel(this.position); + if (point) { + div.style.left = point.x - 12 + 'px'; + div.style.top = point.y - 12 + 'px'; + } } }; diff --git a/htdocs/map.html b/htdocs/map.html index 242608eb..71e0b5d8 100644 --- a/htdocs/map.html +++ b/htdocs/map.html @@ -6,14 +6,15 @@ - + + ${header} -
+

Colors

diff --git a/htdocs/map.js b/htdocs/map.js index decdf829..f91bae07 100644 --- a/htdocs/map.js +++ b/htdocs/map.js @@ -149,7 +149,7 @@ MapManager.prototype.initializeMap = function(receiver_gps, api_key) { MapManager.prototype.processUpdates = function(updates) { var self = this; - if (typeof(GMarker) == 'undefined') { + if (typeof(GMarker) === 'undefined') { updateQueue = updateQueue.concat(updates); return; } @@ -174,7 +174,7 @@ MapManager.prototype.processUpdates = function(updates) { self.mman.addType(update.mode); self.mman.add(update.callsign, marker); marker.addListener('click', function() { - showMarkerInfoWindow(update.callsign, marker.pos); + showMarkerInfoWindow(update.callsign, marker.position); }); } @@ -188,13 +188,13 @@ MapManager.prototype.processUpdates = function(updates) { marker.setMarkerOptions(aprsOptions); if (expectedCallsign && expectedCallsign == update.callsign) { - map.panTo(marker.pos); - showMarkerInfoWindow(update.callsign, marker.pos); + map.panTo(marker.position); + showMarkerInfoWindow(update.callsign, marker.position); expectedCallsign = false; } if (infoWindow && infoWindow.callsign && infoWindow.callsign == update.callsign) { - showMarkerInfoWindow(infoWindow.callsign, marker.pos); + showMarkerInfoWindow(infoWindow.callsign, marker.position); } break; @@ -220,7 +220,7 @@ MapManager.prototype.processUpdates = function(updates) { self.mman.addType(update.mode); self.mman.add(update.callsign, marker); marker.addListener('click', function() { - showMarkerInfoWindow(update.callsign, marker.pos); + showMarkerInfoWindow(update.callsign, marker.position); }); } @@ -234,13 +234,13 @@ MapManager.prototype.processUpdates = function(updates) { marker.setMarkerOptions(options); if (expectedCallsign && expectedCallsign == update.callsign) { - map.panTo(marker.pos); - showMarkerInfoWindow(update.callsign, marker.pos); + map.panTo(marker.position); + showMarkerInfoWindow(update.callsign, marker.position); expectedCallsign = false; } if (infoWindow && infoWindow.callsign && infoWindow.callsign == update.callsign) { - showMarkerInfoWindow(infoWindow.callsign, marker.pos); + showMarkerInfoWindow(infoWindow.callsign, marker.position); } break; diff --git a/htdocs/mapLeaflet.js b/htdocs/mapLeaflet.js new file mode 100644 index 00000000..fe363e58 --- /dev/null +++ b/htdocs/mapLeaflet.js @@ -0,0 +1,312 @@ +// Marker.linkify() uses these URLs +var callsign_url = null; +var vessel_url = null; +var flight_url = null; + +// reasonable default; will be overriden by server +var retention_time = 2 * 60 * 60 * 1000; + +// Our Leaflet Map and layerControl +var map = null; +var layerControl; + +// Receiver location marker +var receiverMarker = null; + +// Updates are queued here +var updateQueue = []; + +// Web socket connection management, message processing +var mapManager = new MapManager(); + +// icons cache +var icons = {}; + +var query = window.location.search.replace(/^\?/, '').split('&').map(function(v){ + var s = v.split('='); + var r = {}; + r[s[0]] = s.slice(1).join('='); + return r; +}).reduce(function(a, b){ + return a.assign(b); +}); + +var expectedCallsign = query.callsign? decodeURIComponent(query.callsign) : null; +var expectedLocator = query.locator? query.locator : null; + +// https://stackoverflow.com/a/46981806/420585 +function fetchStyleSheet(url, media = 'screen') { + let $dfd = $.Deferred(), + finish = () => $dfd.resolve(), + $link = $(document.createElement('link')).attr({ + media, + type: 'text/css', + rel: 'stylesheet' + }) + .on('load', 'error', finish) + .appendTo('head'), + $img = $(document.createElement('img')) + .on('error', finish); // Support browsers that don't fire events on link elements + $link[0].href = $img[0].src = url; + return $dfd.promise(); +} + + + +// Show information bubble for a locator +function showLocatorInfoWindow(locator, pos) { + var p = new posObj(pos); + + L.popup(pos, { + content: mapManager.lman.getInfoHTML(locator, p, receiverMarker) + }).openOn(map); +}; + +// Show information bubble for a marker +function showMarkerInfoWindow(name, pos) { + var marker = mapManager.mman.find(name); + L.popup(pos, { content: marker.getInfoHTML(name, receiverMarker) }).openOn(map); +}; + +// +// Leaflet-SPECIFIC MAP MANAGER METHODS +// + +MapManager.prototype.setReceiverName = function(name) { + if (receiverMarker) receiverMarker.setTitle(name); +} + +MapManager.prototype.removeReceiver = function() { + if (receiverMarker) receiverMarker.setMap(); +} + +MapManager.prototype.initializeMap = function(receiver_gps, api_key) { + var receiverPos = [ receiver_gps.lat, receiver_gps.lon ]; + + if (map) { + receiverMarker.setLatLng(receiverPos); + receiverMarker.setMarkerOptions(this.config); + receiverMarker.setMap(map); + } else { + var self = this; + + // load Leaflet CSS first + fetchStyleSheet('https://unpkg.com/leaflet@1.9.4/dist/leaflet.css').done(function () { + // now load Leaflet JS + $.getScript('https://unpkg.com/leaflet@1.9.4/dist/leaflet.js').done(function () { + // create map + map = L.map('openwebrx-map').setView(receiverPos, 5); + baseLayer = L.tileLayer('https://tile.openstreetmap.org/{z}/{x}/{y}.png', { + maxZoom: 19, + noWrap: true, + attribution: '© OpenStreetMap' + }).addTo(map); + // add night overlay + $.getScript('https://unpkg.com/@joergdietrich/leaflet.terminator@1.0.0/L.Terminator.js').done(function () { + var pane = map.createPane('nite'); + pane.style.zIndex = 201; + pane.style.pointerEvents = 'none !important'; + pane.style.cursor = 'grab !important'; + var t = L.terminator({ fillOpacity: 0.2, interactive: false, pane }); + t.addTo(map); + setInterval(function () { t.setTime(); }, 10000); // refresh every 10 secs + }); + + // create layerControl and add more maps + if (!layerControl) { + var OpenTopoMap = L.tileLayer('https://{s}.tile.opentopomap.org/{z}/{x}/{y}.png', { + maxZoom: 17, + noWrap: true, + attribution: 'Map data: © OpenStreetMap contributors, SRTM | Map style: © OpenTopoMap (CC-BY-SA)' + }); + var Stadia_AlidadeSmooth = L.tileLayer('https://tiles.stadiamaps.com/tiles/alidade_smooth/{z}/{x}/{y}{r}.png', { + maxZoom: 20, + noWrap: true, + attribution: '© Stadia Maps, © OpenMapTiles © OpenStreetMap contributors', + }); + var Esri_WorldTopoMap = L.tileLayer('https://server.arcgisonline.com/ArcGIS/rest/services/World_Topo_Map/MapServer/tile/{z}/{y}/{x}', { + noWrap: true, + attribution: 'Tiles © Esri — Esri, DeLorme, NAVTEQ, TomTom, Intermap, iPC, USGS, FAO, NPS, NRCAN, GeoBase, Kadaster NL, Ordnance Survey, Esri Japan, METI, Esri China (Hong Kong), and the GIS User Community' + }); + var Stadia_AlidadeSmoothDark = L.tileLayer('https://tiles.stadiamaps.com/tiles/alidade_smooth_dark/{z}/{x}/{y}{r}.png', { + maxZoom: 20, + noWrap: true, + attribution: '© Stadia Maps, © OpenMapTiles © OpenStreetMap contributors' + }); + + // used to open or collaps the layerControl by default + function isMobile () { + try { document.createEvent("TouchEvent"); return true; } + catch (e) { return false; } + } + + layerControl = L.control.layers({ + 'OSM': baseLayer, + 'StadiaAlidade': Stadia_AlidadeSmooth, + 'StadiaAlidadeDark': Stadia_AlidadeSmoothDark, + 'EsriWorldTopo': Esri_WorldTopoMap, + 'OpenTopoMap': OpenTopoMap, + }, null, { + collapsed: isMobile(), hideSingleBase: true, position: 'bottomleft' + } + ).addTo(map); + + // move legend div to our layerControl + $('
').insertAfter(layerControl._overlaysList); + layerControl.legend = $('.openwebrx-map-legend').insertAfter($('#openwebrx-map-legend-separator')); + } // layerControl + + // Load and initialize OWRX-specific map item managers + $.getScript('static/lib/Leaflet.js').done(function() { + // Process any accumulated updates + self.processUpdates(updateQueue); + updateQueue = []; + + if (!receiverMarker) { + receiverMarker = new LMarker(); + receiverMarker.setMarkerPosition(self.config['receiver_name'], receiverPos[0], receiverPos[1]); + receiverMarker.addListener('click', function () { + L.popup(receiverMarker.getPos(), { + content: '

' + self.config['receiver_name'] + '

' + + '
Receiver location
' + }).openOn(map); + }); + receiverMarker.setMarkerOptions(this.config); + receiverMarker.setMap(map); + } + }); + + // Create map legend selectors + self.setupLegendFilters(layerControl.legend); + + }); // leaflet.js + }); // leaflet.css + } +}; + +MapManager.prototype.processUpdates = function(updates) { + var self = this; + + if (typeof(LMarker) === 'undefined') { + updateQueue = updateQueue.concat(updates); + return; + } + + updates.forEach(function(update) { + switch (update.location.type) { + case 'latlon': + var marker = self.mman.find(update.callsign); + var markerClass = LMarker; + var aprsOptions = {} + + if (update.location.symbol) { + markerClass = LAprsMarker; + aprsOptions.symbol = update.location.symbol; + aprsOptions.course = update.location.course; + aprsOptions.speed = update.location.speed; + } + + // If new item, create a new marker for it + if (!marker) { + marker = new markerClass(); + self.mman.addType(update.mode); + self.mman.add(update.callsign, marker); + marker.addListener('click', function() { + showMarkerInfoWindow(update.callsign, marker.getPos()); + }); + marker.div = marker.create(); + marker.setIcon(L.divIcon({ html: marker.div, className: 'dummy' })); + } + + // Update marker attributes and age + marker.update(update); + // Assign marker to map + marker.setMap(self.mman.isEnabled(update.mode)? map : undefined); + + // Apply marker options + marker.setMarkerOptions(aprsOptions); + + if (expectedCallsign && expectedCallsign == update.callsign) { + map.setView(marker.getPos()); + showMarkerInfoWindow(update.callsign, marker.getPos()); + expectedCallsign = false; + } + break; + + case 'feature': + var marker = self.mman.find(update.callsign); + var options = {}; + + // If no symbol or color supplied, use defaults by type + if (update.location.symbol) { + options.symbol = update.location.symbol; + } else { + options.symbol = self.mman.getSymbol(update.mode); + } + if (update.location.color) { + options.color = update.location.color; + } else { + options.color = self.mman.getColor(update.mode); + } + + // If new item, create a new marker for it + if (!marker) { + marker = new LFeatureMarker(); + if (!icons[update.mode]) { + icons[update.mode] = L.icon({ + iconUrl: 'static/gfx/icon-' + update.mode + '.png', + iconSize: [24, 24], + }); + } + marker.setIcon(icons[update.mode]); + self.mman.addType(update.mode); + self.mman.add(update.callsign, marker); + marker.addListener('click', function() { + showMarkerInfoWindow(update.callsign, marker.getPos()); + }); + } + + // Update marker attributes and age + marker.update(update); + + // Assign marker to map + marker.setMap(self.mman.isEnabled(update.mode)? map : undefined); + + // Apply marker options + marker.setMarkerOptions(options); + + if (expectedCallsign && expectedCallsign == update.callsign) { + map.setView(marker.getPos()); + showMarkerInfoWindow(update.callsign, marker.getPos()); + expectedCallsign = false; + } + break; + + case 'locator': + var rectangle = self.lman.find(update.callsign); + + // If new item, create a new locator for it + if (!rectangle) { + rectangle = new LLocator(); + self.lman.add(update.callsign, rectangle); + rectangle.addListener('click', function() { + showLocatorInfoWindow(rectangle.locator, rectangle.center); + }); + } + + // Update locator attributes, center, age + rectangle.update(update); + + // Assign locator to map and set its color + rectangle.setMap(self.lman.filter(rectangle)? map : undefined); + rectangle.setColor(self.lman.getColor(rectangle)); + + if (expectedLocator && expectedLocator == update.location.locator) { + map.setView(rectangle.center); + showLocatorInfoWindow(expectedLocator, rectangle.center); + expectedLocator = false; + } + break; + } + }); +}; diff --git a/owrx/controllers/assets.py b/owrx/controllers/assets.py index 52755acd..7cb50214 100644 --- a/owrx/controllers/assets.py +++ b/owrx/controllers/assets.py @@ -151,6 +151,16 @@ class CompiledAssetsController(GzipMixin, ModificationAwareController): "lib/Clock.js", "map.js", ], + "mapLeaflet.js": [ + "lib/jquery-3.2.1.min.js", + "lib/chroma.min.js", + "lib/Header.js", + "lib/MapLocators.js", + "lib/MapMarkers.js", + "lib/MapManager.js", + "lib/Clock.js", + "mapLeaflet.js", + ], "settings.js": [ "lib/jquery-3.2.1.min.js", "lib/bootstrap.bundle.min.js",