diff --git a/htdocs/css/map.css b/htdocs/css/map.css index 70702b96..2ff1e6a2 100644 --- a/htdocs/css/map.css +++ b/htdocs/css/map.css @@ -30,6 +30,24 @@ ul { user-select: none; } +.openwebrx-arrow-up { + margin-top: 5px; + width: 0; + height: 0; + border-left: 10px solid transparent; + border-right: 10px solid transparent; + border-bottom: 10px solid black; +} + +.openwebrx-arrow-down { + margin-top: 5px; + width: 0; + height: 0; + border-left: 10px solid transparent; + border-right: 10px solid transparent; + border-top: 10px solid black; +} + /* show it as soon as google maps has moved it to its container */ .openwebrx-map .openwebrx-map-legend { display: block; diff --git a/htdocs/include/header.include.html b/htdocs/include/header.include.html index 203d6e41..6b8ae430 100644 --- a/htdocs/include/header.include.html +++ b/htdocs/include/header.include.html @@ -10,7 +10,7 @@

Status

Log

Receiver
-
Map
+
Map

Settings
diff --git a/htdocs/lib/Clock.js b/htdocs/lib/Clock.js new file mode 100644 index 00000000..5a9c2eac --- /dev/null +++ b/htdocs/lib/Clock.js @@ -0,0 +1,21 @@ +function Clock(el) { + // Save HTML element to update + this.el = el; + // Update for the first time + this.update(); +} + +Clock.prototype.update = function() { + const now = new Date(); + const me = this; + + // Next update at the next minute change + setTimeout(function() { me.update(); }, 1000 * (60 - now.getUTCSeconds())); + + // Display UTC clock + if (this.el) { + const hours = ("00" + now.getUTCHours()).slice(-2); + const minutes = ("00" + now.getUTCMinutes()).slice(-2); + this.el.html(`${hours}:${minutes} UTC`); + } +} diff --git a/htdocs/lib/GoogleMaps.js b/htdocs/lib/GoogleMaps.js new file mode 100644 index 00000000..92b27dd1 --- /dev/null +++ b/htdocs/lib/GoogleMaps.js @@ -0,0 +1,143 @@ +// +// GoogleMaps-Specific Marker +// + +function GMarker() {} +GMarker.prototype = new google.maps.OverlayView(); + +GMarker.prototype.setMarkerOptions = function(options) { + this.setOptions(options); + this.draw(); +}; + +GMarker.prototype.onAdd = function() { + // Create HTML elements representing the mark + var div = this.create(); + + var self = this; + google.maps.event.addDomListener(div, "click", function(event) { + event.stopPropagation(); + google.maps.event.trigger(self, "click", event); + }); + + var panes = this.getPanes(); + panes.overlayImage.appendChild(div); +}; + +GMarker.prototype.getAnchorPoint = function() { + var offset = this.getAnchorOffset(); + return new google.maps.Point(offset[0], offset[1]); +}; + +GMarker.prototype.setMarkerOpacity = function(opacity) { + this.setOptions({ opacity: opacity }); +}; + +GMarker.prototype.setMarkerPosition = function(title, lat, lon) { + this.setOptions({ + title : title, + position : new google.maps.LatLng(lat, lon) + }); +}; + +// +// GoogleMaps-Specific FeatureMarker +// + +function GFeatureMarker() { $.extend(this, new FeatureMarker()); } +GFeatureMarker.prototype = new GMarker(); + +GFeatureMarker.prototype.place = function() { + // Project location and place symbol + var div = this.div; + if (div) { + 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'; + } + } +}; + +// +// GoogleMaps-Specific AprsMarker +// + +function GAprsMarker() { $.extend(this, new AprsMarker()); } +GAprsMarker.prototype = new GMarker(); + +GAprsMarker.prototype.place = function() { + // Project location and place symbol + var div = this.div; + if (div) { + var point = this.getProjection().fromLatLngToDivPixel(this.position); + if (point) { + div.style.left = point.x - 12 + 'px'; + div.style.top = point.y - 12 + 'px'; + } + } +}; + +// +// GoogleMaps-Specific SimpleMarker +// + +function GSimpleMarker() { $.extend(this, new AprsMarker()); } +GSimpleMarker.prototype = new google.maps.Marker(); + +GSimpleMarker.prototype.setMarkerOpacity = function(opacity) { + this.setOptions({ opacity: opacity }); +}; + +GSimpleMarker.prototype.setMarkerPosition = function(title, lat, lon) { + this.setOptions({ + title : title, + position : new google.maps.LatLng(lat, lon) + }); +}; + +GSimpleMarker.prototype.setMarkerOptions = function(options) { + this.setOptions(options); + this.draw(); +}; + +// +// GoogleMaps-Specific Locator +// + +function GLocator() { + this.rect = new google.maps.Rectangle(); + this.rect.setOptions({ + strokeWeight : 2, + strokeColor : "#FFFFFF", + fillColor : "#FFFFFF" + }); +} + +GLocator.prototype = new Locator(); + +GLocator.prototype.setMap = function(map) { + this.rect.setMap(map); +}; + +GLocator.prototype.setCenter = function(lat, lon) { + this.center = new google.maps.LatLng({lat: lat, lng: lon}); + + this.rect.setOptions({ bounds : { + north : lat - 0.5, + south : lat + 0.5, + west : lon - 1.0, + east : lon + 1.0 + }}); +} + +GLocator.prototype.setColor = function(color) { + this.rect.setOptions({ strokeColor: color, fillColor: color }); +}; + +GLocator.prototype.setOpacity = function(opacity) { + this.rect.setOptions({ + strokeOpacity : LocatorManager.strokeOpacity * opacity, + fillOpacity : LocatorManager.fillOpacity * opacity + }); +}; diff --git a/htdocs/lib/Leaflet.js b/htdocs/lib/Leaflet.js new file mode 100644 index 00000000..589c1272 --- /dev/null +++ b/htdocs/lib/Leaflet.js @@ -0,0 +1,118 @@ +// +// Leaflet-Specific Marker +// + +function LMarker () { + this._marker = L.marker(); +}; + +LMarker.prototype.onAdd = function() { + this.div = this.create(); + + var offset = this.getAnchorOffset(); + + this.setIcon(L.divIcon({ + html : this.div, + iconAnchor : [-offset[1], -offset[0]], + className : 'dummy' + })); +}; + +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/MapLocators.js b/htdocs/lib/MapLocators.js new file mode 100644 index 00000000..84d1744d --- /dev/null +++ b/htdocs/lib/MapLocators.js @@ -0,0 +1,208 @@ +// +// Map Locators Management +// + +LocatorManager.strokeOpacity = 0.8; +LocatorManager.fillOpacity = 0.35; +LocatorManager.allRectangles = function() { return true; }; + +function LocatorManager() { + // Current rectangles + this.rectangles = {}; + + // Current color allocations + this.colorKeys = {}; + + // The color scale used + this.colorScale = chroma.scale(['red', 'blue', 'green']).mode('hsl'); + + // Current coloring mode + this.colorMode = 'byband'; + + // Current filter + this.rectangleFilter = LocatorManager.allRectangles; +} + +LocatorManager.prototype.filter = function(data) { + return this.rectangleFilter(data); +} + +LocatorManager.prototype.find = function(id) { + return id in this.rectangles? this.rectangles[id] : null; +}; + +LocatorManager.prototype.add = function(id, rectangle) { + this.rectangles[id] = rectangle; +}; + +LocatorManager.prototype.ageAll = function() { + var now = new Date().getTime(); + var data = this.rectangles; + $.each(data, function(id, x) { + if (!x.age(now - x.lastseen)) delete data[id]; + }); +}; + +LocatorManager.prototype.clear = function() { + // Remove all rectangles from the map + $.each(this.rectangles, function(_, x) { x.setMap(); }); + // Delete all rectangles + this.rectangles = {}; +}; + +LocatorManager.prototype.setFilter = function(map, filterBy = null) { + if (!filterBy) { + this.rectangleFilter = LocatorManager.allRectangles; + } else { + var key = this.colorMode.slice(2); + this.rectangleFilter = function(x) { + return x[key] === filterBy; + }; + } + + var filter = this.rectangleFilter; + $.each(this.rectangles, function(_, x) { + x.setMap(filter(x) ? map : undefined); + }); +}; + +LocatorManager.prototype.reColor = function() { + var self = this; + $.each(this.rectangles, function(_, x) { + x.setColor(self.getColor(x)); + }); +}; + +LocatorManager.prototype.updateLegend = function() { + if (!this.colorKeys) return; + var filter = this.rectangleFilter; + var mode = this.colorMode.slice(2); + var list = $.map(this.colorKeys, function(value, key) { + // Fake rectangle to test if the filter would match + var fakeRectangle = Object.fromEntries([[mode, key]]); + var disabled = filter(fakeRectangle) ? '' : ' disabled'; + + return '
  • ' + + key + '
  • '; + }); + + $(".openwebrx-map-legend .content").html(''); +} + +LocatorManager.prototype.setColorMode = function(map, newColorMode) { + this.colorMode = newColorMode; + this.colorKeys = {}; + this.setFilter(map); + this.reColor(); + this.updateLegend(); +}; + +LocatorManager.prototype.getType = function(data) { + switch (this.colorMode) { + case 'byband': + return data.band; + case 'bymode': + return data.mode; + default: + return ''; + } +}; + +LocatorManager.prototype.getColor = function(data) { + var type = this.getType(data); + if (!type) return "#ffffff00"; + + // If adding a new key... + if (!this.colorKeys[type]) { + var keys = Object.keys(this.colorKeys); + + // Add a new key + keys.push(type); + + // Sort color keys + keys.sort(function(a, b) { + var pa = parseFloat(a); + var pb = parseFloat(b); + if (isNaN(pa) || isNaN(pb)) return a.localeCompare(b); + return pa - pb; + }); + + // Recompute colors + var colors = this.colorScale.colors(keys.length); + this.colorKeys = {}; + for(var j=0 ; j' + message + ')'; + }).join(""); + + return '

    Locator: ' + locator + distance + + '

    Active Callsigns:
    '; +}; + +// +// Generic Map Locator +// Derived classes have to implement: +// setMap(), setCenter(), setColor(), setOpacity() +// + +function Locator() {} + +Locator.prototype = new Locator(); + +Locator.prototype.update = function(update) { + this.lastseen = update.lastseen; + this.locator = update.location.locator; + this.mode = update.mode; + this.band = update.band; + + // Get locator's lat/lon + const loc = update.location.locator; + const lat = (loc.charCodeAt(1) - 65 - 9) * 10 + Number(loc[3]) + 0.5; + const lon = (loc.charCodeAt(0) - 65 - 9) * 20 + Number(loc[2]) * 2 + 1.0; + + // Implementation-dependent function call + this.setCenter(lat, lon); + + // Age locator + this.age(new Date().getTime() - update.lastseen, update.location.ttl); +}; + +Locator.prototype.age = function(age, ttl=retention_time) { + if (age <= ttl) { + this.setOpacity(Marker.getOpacityScale(age, ttl)); + return true; + } else { + this.setMap(); + return false; + } +}; diff --git a/htdocs/lib/MapManager.js b/htdocs/lib/MapManager.js new file mode 100644 index 00000000..973ef1e7 --- /dev/null +++ b/htdocs/lib/MapManager.js @@ -0,0 +1,207 @@ +// +// Map Manager handles web socket connection and traffic processing +// + +function MapManager() { + var self = this; + + // Determine web socket URL + var protocol = window.location.protocol.match(/https/) ? 'wss' : 'ws'; + var href = window.location.href.replace(/\/[^\/]*$/,''); + href = protocol + '://' + href.split('://')[1]; + this.ws_url = href + (href.endsWith('/')? '':'/') + 'ws/'; + + // Reset everything for now + this.reconnect_timeout = false; + this.config = {}; + + // Markers management (features, APRS, AIS, HFDL, etc) + this.mman = new MarkerManager(); + + // Locators management (FT8, FT4, WSPR, etc) + this.lman = new LocatorManager(); + + // Fade out / remove positions after time + setInterval(function() { + self.lman.ageAll(); + self.mman.ageAll(); + }, 1000); + + // When stuff loads... + $(function () { + // Create clock display + self.clock = new Clock($('#openwebrx-clock-utc')); + + // Clicking clock display toggles legend box on/off + $('#openwebrx-legend-toggle').css({ + 'cursor': 'pointer', + 'display': 'flex', + 'justify-content': 'space-between' + }).on('click', function () { + var el = document.getElementById('openwebrx-map-selectors'); + if (el) { + $(this).find('i').removeClass() + .addClass('openwebrx-arrow-' + (el.style.display === 'none' ? 'down' : 'up')); + el.style.display = el.style.display === 'none' ? 'block' : 'none'; + } + }); + + // Toggle color modes on click + $('#openwebrx-map-colormode').on('change', function() { + self.lman.setColorMode(map, $(this).val()); + }); + }); + + // Connect web socket + this.connect(); +} + +// +// Process a message received over web socket +// +MapManager.prototype.process = function(e) { + if (typeof e.data != 'string') { + console.error("unsupported binary data on websocket; ignoring"); + return + } + + if (e.data.substr(0, 16) == "CLIENT DE SERVER") { + return + } + + try { + var json = JSON.parse(e.data); + switch (json.type) { + case "update": + this.processUpdates(json.value); + break; + + case 'receiver_details': + $().ready(function () { // make sure header is loaded + $('.webrx-top-container').header().setDetails(json.value); + }); + break; + + case "config": + Object.assign(this.config, json.value); + if ('receiver_gps' in this.config) { + // Passing API key even if this particular map + // engine does not need it (Google Maps do) + this.initializeMap( + this.config.receiver_gps, + this.config.google_maps_api_key, + this.config.openweathermap_api_key + ); + } + if ('receiver_name' in this.config) { + this.setReceiverName(this.config.receiver_name); + } + if ('map_position_retention_time' in this.config) { + retention_time = this.config.map_position_retention_time * 1000; + } + if ('callsign_url' in this.config) { + callsign_url = this.config.callsign_url; + } + if ('vessel_url' in this.config) { + vessel_url = this.config.vessel_url; + } + if ('flight_url' in this.config) { + flight_url = this.config.flight_url; + } + if ('modes_url' in this.config) { + modes_url = this.config.modes_url; + } + break; + + default: + console.warn('received message of unknown type: ' + json.type); + } + } catch (e) { + // Don't lose exception + console.error(e); + } +}; + +// +// Connect web socket +// +MapManager.prototype.connect = function() { + var ws = new WebSocket(this.ws_url); + var self = this; + + // When socket opens... + ws.onopen = function() { + ws.send("SERVER DE CLIENT client=map.js type=map"); + self.reconnect_timeout = false + }; + + // When socket closes... + ws.onclose = function() { + // Clear map + self.removeReceiver(); + self.mman.clear(); + self.lman.clear(); + + if (self.reconnect_timeout) { + // Max value: roundabout 8 and a half minutes + self.reconnect_timeout = Math.min(self.reconnect_timeout * 2, 512000); + } else { + // Initial value: 1s + self.reconnect_timeout = 1000; + } + + // Try reconnecting after timeout + setTimeout(function() { self.connect(); }, self.reconnect_timeout); + }; + + // When socket receives a message... + ws.onmessage = function(e) { + self.process(e); + } + + // When socket gets an error... + //ws.onerror = function() { + // console.info("websocket error"); + //}; + + // http://stackoverflow.com/questions/4812686/closing-websocket-correctly-html5-javascript + window.onbeforeunload = function() { + ws.onclose = function () {}; + ws.close(); + }; +}; + +// +// Set up legend filter toggles inside given HTML element. +// +MapManager.prototype.setupLegendFilters = function($legend) { + var self = this; + + $content = $legend.find('.content'); + $content.on('click', 'li', function() { + var $el = $(this); + $lis = $content.find('li'); + if ($lis.hasClass('disabled') && !$el.hasClass('disabled')) { + $lis.removeClass('disabled'); + self.lman.setFilter(map); + } else { + $el.removeClass('disabled'); + $lis.filter(function() { + return this != $el[0] + }).addClass('disabled'); + self.lman.setFilter(map, $el.data('selector')); + } + }); + + $content1 = $legend.find('.features'); + $content1.on('click', 'li', function() { + var $el = $(this); + var onoff = $el.hasClass('disabled'); + if (onoff) { + $el.removeClass('disabled'); + } else { + $el.addClass('disabled'); + } + self.mman.toggle(map, $el.data('selector'), onoff); + }); +}; diff --git a/htdocs/lib/MapMarkers.js b/htdocs/lib/MapMarkers.js new file mode 100644 index 00000000..e2d7a83e --- /dev/null +++ b/htdocs/lib/MapMarkers.js @@ -0,0 +1,619 @@ +// +// Map Markers Management +// + +function MarkerManager() { + // Current markers + this.markers = {}; + + // Currently known marker types + this.types = {}; + + // Colors used for marker types + this.colors = { + 'HFDL' : '#004000', + 'VDL2' : '#000080', + 'ADSB' : '#800000', + 'ACARS' : '#000000' + }; + + // Symbols used for marker types + this.symbols = { + 'APRS' : '⚐', + 'AIS' : '⩯', + 'HFDL' : '✈', + 'VDL2' : '✈', + 'ADSB' : '✈', + 'ACARS' : '✈' + }; + + // Marker type shown/hidden status + this.enabled = { + 'APRS' : true + }; +} + +MarkerManager.prototype.getColor = function(type) { + // Default color is black + return type in this.colors? this.colors[type] : '#000000'; +}; + +MarkerManager.prototype.getSymbol = function(type) { + // Default symbol is a rombus + return type in this.symbols? this.symbols[type] : '◇'; +}; + +MarkerManager.prototype.isEnabled = function(type) { + // Features are shown by default + return type in this.enabled? this.enabled[type] : true; +}; + +MarkerManager.prototype.toggle = function(map, type, onoff) { + // Keep track of each feature table being show or hidden + this.enabled[type] = onoff; + + // Show or hide features on the map + $.each(this.markers, function(_, x) { + if (x.mode === type) x.setMap(onoff ? map : undefined); + }); +}; + +MarkerManager.prototype.addType = function(type) { + // Do not add feature twice + if (type in this.types) return; + + // Determine symbol and its color + var color = this.getColor(type); + var symbol = this.getSymbol(type); + var enabled = this.isEnabled(type); + + // Add type to the list of known types + this.types[type] = symbol; + this.enabled[type] = enabled; + + // If there is a list of features... + var $content = $('.openwebrx-map-legend').find('.features'); + if($content) + { + // Add visual list item for the type + $content.append( + '
  • ' + + '' + + symbol + '' + type + '
  • ' + ); + } +}; + +MarkerManager.prototype.find = function(id) { + return id in this.markers? this.markers[id] : null; +}; + +MarkerManager.prototype.add = function(id, marker) { + this.markers[id] = marker; +}; + +MarkerManager.prototype.ageAll = function() { + var now = new Date().getTime(); + var data = this.markers; + $.each(data, function(id, x) { + if (!x.age(now - x.lastseen)) delete data[id]; + }); +}; + +MarkerManager.prototype.clear = function() { + // Remove all markers from the map + $.each(this.markers, function(_, x) { x.setMap(); }); + // Delete all markers + this.markers = {}; +}; + +// +// Generic Map Marker +// Derived classes have to implement: +// setMap(), setMarkerOpacity() +// + +function Marker() {} + +// Wrap given callsign or other ID into a clickable link. +Marker.linkify = function(name, url = null, linkEntity = null) { + // If no specific link entity, use the ID itself + if (linkEntity == null) linkEntity = name; + + // Must have valid ID and lookup URL + if ((name == '') || (url == null) || (url == '')) { + return name; + } else { + return '' + name + ''; + } +}; + +// Create link to tune OWRX to the given frequency and modulation. +Marker.linkifyFreq = function(freq, mod) { + var text; + if (freq >= 30000000) { + text = "" + (freq / 1000000.0) + "MHz"; + } else if (freq >= 10000) { + text = "" + (freq / 1000.0) + "kHz"; + } else { + text = "" + freq + "Hz"; + } + + return '' + text + ''; +}; + +// Compute distance, in kilometers, between two latlons. +Marker.distanceKm = function(p1, p2) { + // Earth radius in km + var R = 6371.0; + // Convert degrees to radians + var rlat1 = p1.lat() * (Math.PI/180); + var rlat2 = p2.lat() * (Math.PI/180); + // Compute difference in radians + var difflat = rlat2-rlat1; + var difflon = (p2.lng()-p1.lng()) * (Math.PI/180); + // Compute distance + d = 2 * R * Math.asin(Math.sqrt( + Math.sin(difflat/2) * Math.sin(difflat/2) + + Math.cos(rlat1) * Math.cos(rlat2) * Math.sin(difflon/2) * Math.sin(difflon/2) + )); + return Math.round(d); +}; + +// Truncate string to a given number of characters, adding "..." to the end. +Marker.truncate = function(str, count) { + return str.length > count? str.slice(0, count) + '…' : str; +}; + +// Convert degrees to compass direction. +Marker.degToCompass = function(deg) { + dir = ['N', 'NNE', 'NE', 'ENE', 'E', 'ESE', 'SE', 'SSE', 'S', 'SSW', 'SW', 'WSW', 'W', 'WNW', 'NW', 'NNW']; + return dir[Math.floor((deg/22.5) + 0.5) % 16]; +}; + +// Convert given name to an information section title. +Marker.makeListTitle = function(name) { + return '
    ' + name + '
    '; +}; + +// Convert given name/value to an information section item. +Marker.makeListItem = function(name, value) { + return '
    ' + + '' + name + '    ' + + '' + value + '' + + '
    '; +}; + +// Get opacity value in the 0..1 range based on the given age. +Marker.getOpacityScale = function(age, ttl=retention_time) { + var scale = 1; + if (age >= ttl / 2) { + scale = (ttl - age) / (ttl / 2); + } + return Math.max(0, Math.min(1, scale)); +}; + +// Set marker's opacity based on the supplied age. Returns TRUE +// if the marker should still be visible, FALSE if it has to be +// removed. +Marker.prototype.age = function(age, ttl=retention_time) { + if(age <= ttl) { + this.setMarkerOpacity(Marker.getOpacityScale(age, ttl)); + return true; + } else { + this.setMap(); + return false; + } +}; + +// Remove visual marker element from its parent, if that element exists. +Marker.prototype.remove = function() { + if (this.div) { + this.div.parentNode.removeChild(this.div); + this.div = null; + } +}; + +// +// Feature Marker +// Derived classes have to implement: +// setMarkerOpacity() +// + +function FeatureMarker() {} + +FeatureMarker.prototype = new Marker(); + +FeatureMarker.prototype.update = function(update) { + this.lastseen = update.lastseen; + this.mode = update.mode; + this.url = update.location.url; + this.comment = update.location.comment; + this.altitude = update.location.altitude; + this.status = update.location.status; + this.updated = update.location.updated; + this.mmode = update.location.mmode; + // Generic vendor-specific details + this.details = update.location.details; + + // Implementation-dependent function call + this.setMarkerPosition(update.callsign, update.location.lat, update.location.lon); + + // Age locator + this.age(new Date().getTime() - update.lastseen, update.location.ttl); +}; + +FeatureMarker.prototype.draw = function() { + var div = this.div; + if (!div) return; + + div.style.color = this.color? this.color : '#000000'; + div.innerHTML = this.symbol? this.symbol : '●'; + + if (this.place) this.place(); +}; + +FeatureMarker.prototype.create = function() { + var div = this.div = document.createElement('div'); + + // Marker size + this.symWidth = 16; + this.symHeight = 16; + + div.style.position = 'absolute'; + div.style.cursor = 'pointer'; + div.style.width = this.symWidth + 'px'; + div.style.height = this.symHeight + 'px'; + div.style.textAlign = 'center'; + div.style.fontSize = this.symHeight + 'px'; + div.style.lineHeight = this.symHeight + 'px'; + + return div; +}; + +FeatureMarker.prototype.getAnchorOffset = function() { + return [0, -this.symHeight/2]; +}; + +FeatureMarker.prototype.getInfoHTML = function(name, receiverMarker = null) { + var nameString = this.url? Marker.linkify(name, this.url) : name; + var commentString = this.comment? '

    ' + this.comment + '

    ' : ''; + var detailsString = ''; + var scheduleString = ''; + var distance = ''; + + // If it is a repeater, its name is a callsign + if(!this.url && this.freq) { + nameString = Marker.linkify(name, callsign_url); + } + + if (this.altitude) { + detailsString += Marker.makeListItem('Altitude', this.altitude.toFixed(0) + ' m'); + } + + if (this.mmode) { + detailsString += Marker.makeListItem('Modulation', this.mmode.toUpperCase()); + } + + if (!this.comment && this.status && this.updated) { + commentString = '

    ' + this.status + + ', last updated on ' + this.updated + '

    '; + } else { + if (this.status) { + detailsString += Marker.makeListItem('Status', this.status); + } + if (this.updated) { + detailsString += Marker.makeListItem('Updated', this.updated); + } + } + + var moreDetails = this.detailsData; + if (typeof moreDetails === 'object') { + Object.keys(moreDetails).sort().forEach(function (k, i) { + detailsString += Marker.makeListItem(k.charAt(0).toUpperCase() + k.slice(1), moreDetails[k]); + }); + } + + if (detailsString.length > 0) { + detailsString = '
    ' + Marker.makeListTitle('Details') + detailsString + '
    '; + } + + if (scheduleString.length > 0) { + scheduleString = '
    ' + Marker.makeListTitle('Schedule') + scheduleString + '
    '; + } + + if (receiverMarker) { + distance = ' at ' + Marker.distanceKm(receiverMarker.position, this.position) + ' km'; + } + + return '

    ' + nameString + distance + '

    ' + + commentString + detailsString + scheduleString; +}; + +// +// APRS Marker +// Represents APRS transmitters, as well as AIS (vessels) +// and HFDL (planes). +// Derived classes have to implement: +// setMarkerOpacity() +// + +function AprsMarker() {} + +AprsMarker.prototype = new Marker(); + +AprsMarker.prototype.update = function(update) { + this.lastseen = update.lastseen; + this.mode = update.mode; + this.hops = update.hops; + this.band = update.band; + this.comment = update.location.comment; + this.weather = update.location.weather; + this.altitude = update.location.altitude; + this.height = update.location.height; + this.power = update.location.power; + this.gain = update.location.gain; + this.device = update.location.device; + this.directivity = update.location.directivity; + this.aircraft = update.location.aircraft; + this.destination = update.location.destination; + this.origin = update.location.origin; + this.flight = update.location.flight; + this.icao = update.location.icao; + this.vspeed = update.location.vspeed; + + // Implementation-dependent function call + this.setMarkerPosition(update.callsign, update.location.lat, update.location.lon); + + // Age locator + this.age(new Date().getTime() - update.lastseen, update.location.ttl); +}; + +AprsMarker.prototype.isFacingEast = function(symbol) { + var eastward = symbol.table === '/' ? + '(*<=>CFPUXYZabefgjkpsuv[' : '(T`efhjktuvw'; + return eastward.includes(symbol.symbol); +}; + +AprsMarker.prototype.draw = function() { + var div = this.div; + var overlay = this.overlay; + if (!div || !overlay) return; + + if (this.symbol) { + var tableId = this.symbol.table === '/' ? 0 : 1; + div.style.background = 'url(aprs-symbols/aprs-symbols-24-' + tableId + '@2x.png)'; + div.style['background-size'] = '384px 144px'; + div.style['background-position-x'] = -(this.symbol.index % 16) * 24 + 'px'; + div.style['background-position-y'] = -Math.floor(this.symbol.index / 16) * 24 + 'px'; + } + + // If entity is flying at a significant altitude... + if (this.altitude >= 500) { + // r = elevation, a = rotation, = shadow offset + var r = Math.round(this.altitude / 1000); + var a = - Math.PI * (this.course? this.course - 45 : -45) / 180; + var x = r * Math.cos(a); + var y = r * Math.sin(a); + div.style.filter = 'drop-shadow(' + x + 'px ' + y + 'px 0.5px rgba(0,0,0,0.5))'; + } else { + div.style.filter = 'none'; + } + + if (!this.course) { + div.style.transform = null; + } else if (this.symbol && !this.isFacingEast(this.symbol)) { + // Airplanes and other symbols point up (to the north) + div.style.transform = 'rotate(' + this.course + 'deg)'; + } else if (this.course > 180) { + // Vehicles and vessels point right (to the east) + div.style.transform = 'scalex(-1) rotate(' + (270 - this.course) + 'deg)' + } else { + // Vehicles and vessels point right (to the east) + div.style.transform = 'rotate(' + (this.course - 90) + 'deg)'; + } + + if (this.symbol && this.symbol.table !== '/' && this.symbol.table !== '\\') { + overlay.style.display = 'block'; + overlay.style['background-position-x'] = -(this.symbol.tableindex % 16) * 24 + 'px'; + overlay.style['background-position-y'] = -Math.floor(this.symbol.tableindex / 16) * 24 + 'px'; + } else { + overlay.style.display = 'none'; + } + + if (this.opacity) { + div.style.opacity = this.opacity; + } else { + div.style.opacity = null; + } + + if (this.place) this.place(); +}; + +AprsMarker.prototype.create = function() { + var div = this.div = document.createElement('div'); + + div.style.position = 'absolute'; + div.style.cursor = 'pointer'; + div.style.width = '24px'; + div.style.height = '24px'; + + var overlay = this.overlay = document.createElement('div'); + overlay.style.width = '24px'; + overlay.style.height = '24px'; + overlay.style.background = 'url(aprs-symbols/aprs-symbols-24-2@2x.png)'; + overlay.style['background-size'] = '384px 144px'; + overlay.style.display = 'none'; + + div.appendChild(overlay); + + return div; +}; + +AprsMarker.prototype.getAnchorOffset = function() { + return [0, -12]; +}; + +AprsMarker.prototype.getInfoHTML = function(name, receiverMarker = null) { + var timeString = moment(this.lastseen).fromNow(); + var commentString = ''; + var weatherString = ''; + var detailsString = ''; + var hopsString = ''; + var distance = ''; + + if (this.comment) { + commentString += '' + Marker.makeListTitle('Comment') + '
    ' + + this.comment + '
    '; + } + + if (this.weather) { + weatherString += '
    ' + Marker.makeListTitle('Weather'); + + if (this.weather.temperature) { + weatherString += Marker.makeListItem('Temperature', this.weather.temperature.toFixed(1) + ' oC'); + } + + if (this.weather.humidity) { + weatherString += Marker.makeListItem('Humidity', this.weather.humidity + '%'); + } + + if (this.weather.barometricpressure) { + weatherString += Marker.makeListItem('Pressure', this.weather.barometricpressure.toFixed(1) + ' mbar'); + } + + if (this.weather.wind) { + if (this.weather.wind.speed && (this.weather.wind.speed>0)) { + weatherString += Marker.makeListItem('Wind', + Marker.degToCompass(this.weather.wind.direction) + ' ' + + this.weather.wind.speed.toFixed(1) + ' km/h ' + ); + } + + if (this.weather.wind.gust && (this.weather.wind.gust>0)) { + weatherString += Marker.makeListItem('Gusts', this.weather.wind.gust.toFixed(1) + ' km/h'); + } + } + + if (this.weather.rain && (this.weather.rain.day>0)) { + weatherString += Marker.makeListItem('Rain', + this.weather.rain.hour.toFixed(0) + ' mm/h, ' + + this.weather.rain.day.toFixed(0) + ' mm/day' +// this.weather.rain.sincemidnight + ' mm since midnight' + ); + } + + if (this.weather.snowfall) { + weatherString += Marker.makeListItem('Snow', this.weather.snowfall.toFixed(1) + ' cm'); + } + + weatherString += '
    '; + } + + if (this.height) { + detailsString += Marker.makeListItem('Height', this.height.toFixed(0) + ' m'); + } + + if (this.power) { + detailsString += Marker.makeListItem('Power', this.power + ' W'); + } + + if (this.gain) { + detailsString += Marker.makeListItem('Gain', this.gain + ' dB'); + } + + if (this.directivity) { + detailsString += Marker.makeListItem('Direction', this.directivity); + } + + if (this.icao) { + detailsString += Marker.makeListItem('ICAO', Marker.linkify(this.icao, modes_url)); + } + + if (this.aircraft) { + detailsString += Marker.makeListItem('Aircraft', Marker.linkify(this.aircraft, flight_url)); + } + + if (this.origin) { + detailsString += Marker.makeListItem('Origin', this.origin); + } + + if (this.destination) { + detailsString += Marker.makeListItem('Destination', this.destination); + } + + // Combine course and speed if both present + if (this.course && this.speed) { + detailsString += Marker.makeListItem('Course', + Marker.degToCompass(this.course) + ' ' + + this.speed.toFixed(1) + ' km/h' + ); + } else { + if (this.course) { + detailsString += Marker.makeListItem('Course', Marker.degToCompass(this.course)); + } + if (this.speed) { + detailsString += Marker.makeListItem('Speed', this.speed.toFixed(1) + ' km/h'); + } + } + + // Combine altitude and vertical speed + if (this.altitude) { + var alt = this.altitude.toFixed(0) + ' m'; + if (this.vspeed > 0) alt += ' ↗' + this.vspeed + ' m/m'; + else if (this.vspeed < 0) alt += ' ↘' + (-this.vspeed) + ' m/m'; + detailsString += Marker.makeListItem('Altitude', alt); + } + + if (detailsString.length > 0) { + detailsString = '
    ' + Marker.makeListTitle('Details') + detailsString + '
    '; + } + + if (receiverMarker) { + distance = ' at ' + Marker.distanceKm(receiverMarker.position, this.position) + ' km'; + } + + if (this.hops && this.hops.length > 0) { + var hops = this.hops.toString().split(','); + hops.forEach(function(part, index, hops) { + hops[index] = Marker.linkify(part, callsign_url); + }); + + hopsString = '

    via ' + hops.join(', ') + ' 

    '; + } + + // Linkify title based on what it is (vessel, flight, mode-S code, or else) + var linkEntity = null; + var url = null; + switch (this.mode) { + case 'HFDL': + case 'VDL2': + case 'ADSB': + case 'ACARS': + if (this.flight) { + name = this.flight; + url = this.flight.match(/^[A-Z]{3}[0-9]+[A-Z]*$/)? flight_url : null; + } else if (this.aircraft) { + name = this.aircraft; + url = flight_url; + } else { + url = modes_url; + } + break; + case 'AIS': + url = vessel_url; + break; + default: + linkEntity = name.replace(new RegExp('-.*$'), ''); + url = callsign_url; + break; + } + + return '

    ' + Marker.linkify(name, url, linkEntity) + distance + '

    ' + + '

    ' + timeString + ' using ' + this.mode + + ( this.band ? ' on ' + this.band : '' ) + '

    ' + + commentString + weatherString + detailsString + hopsString; +}; diff --git a/htdocs/map-google.html b/htdocs/map-google.html new file mode 100644 index 00000000..de382c6a --- /dev/null +++ b/htdocs/map-google.html @@ -0,0 +1,36 @@ + + + + OpenWebRX Map + + + + + + + + + + + ${header} +
    +
    +
    +

    Colors

    + +
    +

    Features

    +
      +
    +
    +
    + +

    00:00 UTC

    +
    +
    + + diff --git a/htdocs/map-google.js b/htdocs/map-google.js new file mode 100644 index 00000000..2630cc39 --- /dev/null +++ b/htdocs/map-google.js @@ -0,0 +1,310 @@ +// Marker.linkify() uses these URLs +var callsign_url = null; +var vessel_url = null; +var flight_url = null; +var modes_url = null; + +// reasonable default; will be overriden by server +var retention_time = 2 * 60 * 60 * 1000; + +// Our Google Map +var map = null; + +// Receiver location marker +var receiverMarker = null; + +// Information bubble window +var infoWindow = null; + +// Updates are queued here +var updateQueue = []; + +// Web socket connection management, message processing +var mapManager = new MapManager(); + +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; +var expectedIcao = query.icao? query.icao: null; + +// Get information bubble window +function getInfoWindow() { + if (!infoWindow) { + infoWindow = new google.maps.InfoWindow(); + google.maps.event.addListener(infoWindow, 'closeclick', function() { + delete infoWindow.locator; + delete infoWindow.callsign; + }); + } + delete infoWindow.locator; + delete infoWindow.callsign; + return infoWindow; +}; + +// Show information bubble for a locator +function showLocatorInfoWindow(locator, pos) { + var iw = getInfoWindow(); + + iw.locator = locator; + iw.setContent(mapManager.lman.getInfoHTML(locator, pos, receiverMarker)); + iw.setPosition(pos); + iw.open(map); +}; + +// Show information bubble for a marker +function showMarkerInfoWindow(name, pos) { + var marker = mapManager.mman.find(name); + var iw = getInfoWindow(); + + iw.callsign = name; + iw.setContent(marker.getInfoHTML(name, receiverMarker)); + iw.open(map, marker); +}; + +// Show information bubble for the receiver location +function showReceiverInfoWindow(marker) { + var iw = getInfoWindow() + iw.setContent( + '

    ' + marker.config['receiver_name'] + '

    ' + + '
    Receiver Location
    ' + ); + iw.open(map, marker); +}; + +var sourceToKey = function(source) { + // special treatment for special entities + // not just for display but also in key treatment in order not to overlap with other locations sent by the same callsign + if ('item' in source) return source['item']; + if ('object' in source) return source['object']; + if ('icao' in source) return source['icao']; + if ('flight' in source) return source['flight']; + var key = source.callsign; + if ('ssid' in source) key += '-' + source.ssid; + return key; +}; + +// we can reuse the same logic for displaying and indexing +var sourceToString = sourceToKey; + +// +// GOOGLE-SPECIFIC MAP MANAGER METHODS +// + +MapManager.prototype.setReceiverName = function(name) { + if (receiverMarker) receiverMarker.setOptions({ title: name }); +} + +MapManager.prototype.removeReceiver = function() { + if (receiverMarker) receiverMarker.setMap(); +} + +MapManager.prototype.initializeMap = function(receiver_gps, api_key, weather_key) { + var receiverPos = { lat: receiver_gps.lat, lng: receiver_gps.lon }; + + if (map) { + receiverMarker.setOptions({ + map : map, + position : receiverPos, + config : this.config + }); + } else { + var self = this; + + // After Google Maps API loads... + $.getScript("https://maps.googleapis.com/maps/api/js?key=" + api_key).done(function() { + // Create a map instance + map = new google.maps.Map($('.openwebrx-map')[0], { + center : receiverPos, + zoom : 5, + }); + + // Load and initialize day-and-night overlay + $.getScript("static/lib/nite-overlay.js").done(function() { + nite.init(map); + setInterval(function() { nite.refresh() }, 10000); // every 10s + }); + + // Load and initialize OWRX-specific map item managers + $.getScript('static/lib/GoogleMaps.js').done(function() { + // Process any accumulated updates + self.processUpdates(updateQueue); + updateQueue = []; + }); + + // Create map legend selectors + var $legend = $(".openwebrx-map-legend"); + self.setupLegendFilters($legend); + map.controls[google.maps.ControlPosition.LEFT_BOTTOM].push($legend[0]); + + // Create receiver marker + if (!receiverMarker) { + receiverMarker = new google.maps.Marker(); + receiverMarker.addListener('click', function() { + showReceiverInfoWindow(receiverMarker); + }); + } + + // Set receiver marker position, name, etc. + receiverMarker.setOptions({ + map : map, + position : receiverPos, + title : self.config['receiver_name'], + config : self.config + }); + }); + } +}; + +MapManager.prototype.processUpdates = function(updates) { + var self = this; + + if (typeof(GMarker) === 'undefined') { + updateQueue = updateQueue.concat(updates); + return; + } + + updates.forEach(function(update) { + var key = sourceToKey(update.source); + + switch (update.location.type) { + case 'latlon': + var marker = self.mman.find(key); + var markerClass = GSimpleMarker; + var aprsOptions = {} + + if (update.location.symbol) { + markerClass = GAprsMarker; + 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) { + // AF: here shall be created ICAO markers for planes. + // either by adapting the PlaneMarker.js or by reusing the AprsMarkers as in OWRX+ + // I'll leave this to someone more competent or will try to implement it myself + // when I have the time to spend to understand how. + // As of now, the planes are shown on the map, but with default icon. + marker = new markerClass(); + self.mman.add(key, marker); + marker.addListener('click', function() { + showMarkerInfoWindow(key, marker.position); + }); + } + + // Keep track of new marker types as they may change + self.mman.addType(update.mode); + + // 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 (expectedIcao && expectedIcao === update.source.icao) { + map.panTo(marker.position); + showMarkerInfoWindow(key, marker.position); + expectedIcao = false; + } + + if (expectedCallsign && expectedCallsign == key) { + map.panTo(marker.position); + showMarkerInfoWindow(key, marker.position); + expectedCallsign = false; + } + + if (infoWindow && infoWindow.callsign && infoWindow.callsign == key) { + showMarkerInfoWindow(infoWindow.callsign, marker.position); + } + break; + + case 'feature': + var marker = self.mman.find(key); + 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 GFeatureMarker(); + self.mman.addType(update.mode); + self.mman.add(key, marker); + marker.addListener('click', function() { + showMarkerInfoWindow(key, marker.position); + }); + } + + // 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 == key) { + map.panTo(marker.position); + showMarkerInfoWindow(key, marker.position); + expectedCallsign = false; + } + + if (infoWindow && infoWindow.callsign && infoWindow.callsign == key) { + showMarkerInfoWindow(infoWindow.callsign, marker.position); + } + break; + + case 'locator': + var rectangle = self.lman.find(key); + + // If new item, create a new locator for it + if (!rectangle) { + rectangle = new GLocator(); + self.lman.add(key, rectangle); + rectangle.rect.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.panTo(rectangle.center); + showLocatorInfoWindow(expectedLocator, rectangle.center); + expectedLocator = false; + } + + if (infoWindow && infoWindow.locator && infoWindow.locator == update.location.locator) { + showLocatorInfoWindow(infoWindow.locator, rectangle.center); + } + break; + } + }); +}; diff --git a/htdocs/map-leaflet.html b/htdocs/map-leaflet.html new file mode 100644 index 00000000..6ec55dba --- /dev/null +++ b/htdocs/map-leaflet.html @@ -0,0 +1,39 @@ + + + + OpenWebRX Map + + + + + + + + + + + ${header} +
    +
    +
    +

    Map

    + +
    +

    Colors

    + +
    +

    Features

    +
      +
    +
    +
    + +

    00:00 UTC

    +
    +
    + + diff --git a/htdocs/map-leaflet.js b/htdocs/map-leaflet.js new file mode 100644 index 00000000..17c3a179 --- /dev/null +++ b/htdocs/map-leaflet.js @@ -0,0 +1,466 @@ +// Marker.linkify() uses these URLs +var callsign_url = null; +var vessel_url = null; +var flight_url = null; +var modes_url = null; + +var mapSources = [ + { + name: 'OpenStreetMap', + url: 'https://tile.openstreetmap.org/{z}/{x}/{y}.png', + options: { + maxZoom: 19, + noWrap: true, + attribution: '© OpenStreetMap' + }, + }, + { + name: 'OpenTopoMap', + url: 'https://{s}.tile.opentopomap.org/{z}/{x}/{y}.png', + options: { + maxZoom: 17, + noWrap: true, + attribution: 'Map data: © OpenStreetMap contributors, SRTM | Map style: © OpenTopoMap (CC-BY-SA)' + } + }, + { + name: 'Esri WorldTopo', + url: 'https://server.arcgisonline.com/ArcGIS/rest/services/World_Topo_Map/MapServer/tile/{z}/{y}/{x}', + config: { + 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' + } + }, + { + name: 'Esri WorldStreet', + url: 'https://server.arcgisonline.com/ArcGIS/rest/services/World_Street_Map/MapServer/tile/{z}/{y}/{x}', + options: { + attribution: 'Tiles © Esri — Source: Esri, DeLorme, NAVTEQ, USGS, Intermap, iPC, NRCAN, Esri Japan, METI, Esri China (Hong Kong), Esri (Thailand), TomTom, 2012' + } + }, + { + name: 'Esri WorldImagery', + url: 'https://server.arcgisonline.com/ArcGIS/rest/services/World_Imagery/MapServer/tile/{z}/{y}/{x}', + options: { + attribution: 'Tiles © Esri — Source: Esri, i-cubed, USDA, USGS, AEX, GeoEye, Getmapping, Aerogrid, IGN, IGP, UPR-EGP, and the GIS User Community' + } + }, + { + name: 'Esri NatGeoWorld', + url: 'https://server.arcgisonline.com/ArcGIS/rest/services/NatGeo_World_Map/MapServer/tile/{z}/{y}/{x}', + options: { + attribution: 'Tiles © Esri — National Geographic, Esri, DeLorme, NAVTEQ, UNEP-WCMC, USGS, NASA, ESA, METI, NRCAN, GEBCO, NOAA, iPC', + maxZoom: 16 + } + }, + { + name: 'Esri WorldGray', + url: 'https://server.arcgisonline.com/ArcGIS/rest/services/Canvas/World_Light_Gray_Base/MapServer/tile/{z}/{y}/{x}', + options: { + attribution: 'Tiles © Esri — Esri, DeLorme, NAVTEQ', + maxZoom: 16 + } + }, + { + name: 'CartoDB Positron', + url: 'https://{s}.basemaps.cartocdn.com/light_all/{z}/{x}/{y}{r}.png', + options: { + attribution: '© OpenStreetMap contributors © CARTO', + subdomains: 'abcd', + maxZoom: 20 + } + }, + { + name: 'CartoDB DarkMatter', + url: 'https://{s}.basemaps.cartocdn.com/dark_all/{z}/{x}/{y}{r}.png', + options: { + attribution: '© OpenStreetMap contributors © CARTO', + subdomains: 'abcd', + maxZoom: 20 + } + }, + { + name: 'CartoDB Voyager', + url: 'https://{s}.basemaps.cartocdn.com/rastertiles/voyager/{z}/{x}/{y}{r}.png', + options: { + attribution: '© OpenStreetMap contributors © CARTO', + subdomains: 'abcd', + maxZoom: 20 + } + }, + { + name: 'Stadia Alidade', + url: 'https://tiles.stadiamaps.com/tiles/alidade_smooth/{z}/{x}/{y}{r}.png', + config: { + maxZoom: 20, + noWrap: true, + attribution: '© Stadia Maps, © OpenMapTiles © OpenStreetMap contributors', + }, + info: 'In order to use Stadia maps, you must register. Once registered, you can whitelist your domain within your account settings.' + }, + { + name: 'Stadia AlidadeDark', + url: 'https://tiles.stadiamaps.com/tiles/alidade_smooth_dark/{z}/{x}/{y}{r}.png', + config: { + maxZoom: 20, + noWrap: true, + attribution: '© Stadia Maps, © OpenMapTiles © OpenStreetMap contributors' + }, + info: 'In order to use Stadia maps, you must register. Once registered, you can whitelist your domain within your account settings.' + }, +]; + +// 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(); + +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; +var expectedIcao = query.icao? query.icao: 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); +}; + +var sourceToKey = function(source) { + // special treatment for special entities + // not just for display but also in key treatment in order not to overlap with other locations sent by the same callsign + if ('item' in source) return source['item']; + if ('object' in source) return source['object']; + if ('icao' in source) return source['icao']; + if ('flight' in source) return source['flight']; + var key = source.callsign; + if ('ssid' in source) key += '-' + source.ssid; + return key; +}; + +// we can reuse the same logic for displaying and indexing +var sourceToString = sourceToKey; + +// +// 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, weather_key) { + if (map) { + receiverMarker.setLatLng(receiver_gps.lat, receiver_gps.lon); + 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', { zoomControl: false }).setView([receiver_gps.lat, receiver_gps.lon], 5); + + // add zoom control + new L.Control.Zoom({ position: 'bottomright' }).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) { + // 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({ + }, null, { + collapsed: false, //isMobile(), // we have collapsing already made in the utc clock + hideSingleBase: true, + position: 'bottomleft' + } + ).addTo(map); + + // move legend div to our layerControl + layerControl.legend = $('.openwebrx-map-legend') + .css({'padding': '0', 'margin': '0'}) + .insertAfter(layerControl._overlaysList); + } // 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'], receiver_gps.lat, receiver_gps.lon); + receiverMarker.addListener('click', function () { + L.popup(receiverMarker.getPos(), { + content: '

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

    ' + + '
    Receiver location
    ' + }).openOn(map); + }); + receiverMarker.setMarkerOptions(this.config); + receiverMarker.setMap(map); + } + }); + + $.each(mapSources, function (idx, ms) { + $('#openwebrx-map-source').append( + $('') + .attr('selected', idx == 0 ? true : false) + .attr('value', idx) + .attr('title', ms.info) + .text(ms.name) + ); + ms.layer = L.tileLayer(ms.url, ms.options); + if (idx == 0) ms.layer.addTo(map); + }); + + var apiKeys = {}; + if (weather_key) { + apiKeys['weather_key'] = weather_key; + } + + function isMapEligible (m) { + if (!m) return false; + if (!m.depends || !m.depends.length) return true; // if no depends -> true + var looking = m.depends; + var invert = false; + if (looking.charAt(0) === '!') { + invert = true; + looking = looking.slice(1); + } + var eligible = false; // we have deps, so default is false until we find the dep keys + Object.keys(apiKeys).forEach(function (k) { + if (looking === k) eligible = true; // if we have the key and depend on it -> true + }); + return invert ? !eligible : eligible; + } + + $('#openwebrx-map-source').on('change', function (e) { + var id = this.value; + var m = mapSources[id]; + $.each(mapSources, function (idx, ms) { + if (map.hasLayer(ms.layer)) + map.removeLayer(ms.layer); + }); + map.addLayer(m.layer); + }); + + // 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) { + var key = sourceToKey(update.source); + + switch (update.location.type) { + case 'latlon': + var marker = self.mman.find(key); + var aprsOptions = {} + + if (update.location.symbol) { + 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) { + // AF: here shall be created ICAO markers for planes. + // either by adapting the PlaneMarker.js or by reusing the AprsMarkers as in OWRX+ + // I'll leave this to someone more competent or will try to implement it myself + // when I have the time to spend to understand how. + // As of now, the planes are shown on the map, but with default icon. + marker = new LAprsMarker(); + self.mman.add(key, marker); + marker.addListener('click', function() { + showMarkerInfoWindow(key, marker.getPos()); + }); + + // If displaying a symbol, create it + if (update.location.symbol) marker.onAdd(); + } + + // Keep track of new marker types as they may change + self.mman.addType(update.mode); + + // 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 (expectedIcao && expectedIcao === key) { + map.setView(marker.getPos()); + showMarkerInfoWindow(key, marker.getPos()); + expectedIcao = false; + } + + if (expectedCallsign && expectedCallsign == key) { + map.setView(marker.getPos()); + showMarkerInfoWindow(key, marker.getPos()); + expectedCallsign = false; + } + break; + + case 'feature': + var marker = self.mman.find(key); + 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(); + marker.div = marker.create(); + var offset = marker.getAnchorOffset(); + marker.setIcon(L.divIcon({ + html: marker.div, + iconAnchor: [-offset[1], -offset[0]], + className: 'dummy' + })); + + self.mman.addType(update.mode); + self.mman.add(key, marker); + marker.addListener('click', function() { + showMarkerInfoWindow(key, 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 == key) { + map.setView(marker.getPos()); + showMarkerInfoWindow(key, marker.getPos()); + expectedCallsign = false; + } + break; + + case 'locator': + var rectangle = self.lman.find(key); + + // If new item, create a new locator for it + if (!rectangle) { + rectangle = new LLocator(); + self.lman.add(key, 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/config/defaults.py b/owrx/config/defaults.py index 7deeed4a..33acc219 100644 --- a/owrx/config/defaults.py +++ b/owrx/config/defaults.py @@ -147,6 +147,7 @@ defaultConfig = PropertyLayer( tuning_precision=2, squelch_auto_margin=10, google_maps_api_key="", + map_type="google", map_position_retention_time=2 * 60 * 60, decoding_queue_workers=2, decoding_queue_length=10, diff --git a/owrx/connection.py b/owrx/connection.py index 31d19ba6..4ab05e7f 100644 --- a/owrx/connection.py +++ b/owrx/connection.py @@ -458,6 +458,7 @@ class MapConnection(OpenWebRxClient): filtered_config = pm.filter( "google_maps_api_key", "receiver_gps", + "map_type", "callsign_service", "aircraft_tracking_service", "receiver_name", diff --git a/owrx/controllers/assets.py b/owrx/controllers/assets.py index d9a273fa..57f06986 100644 --- a/owrx/controllers/assets.py +++ b/owrx/controllers/assets.py @@ -138,12 +138,32 @@ class CompiledAssetsController(GzipMixin, ModificationAwareController): "lib/Modes.js", "lib/MetaPanel.js", ], - "map.js": [ + "map.js": [ # AF: to be removed once PR is accepted "lib/jquery-3.2.1.min.js", "lib/chroma.min.js", "lib/Header.js", "map.js", ], + "map-google.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", + "map-google.js", + ], + "map-leaflet.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", + "map-leaflet.js", + ], "settings.js": [ "lib/jquery-3.2.1.min.js", "lib/bootstrap.bundle.min.js", diff --git a/owrx/controllers/settings/general.py b/owrx/controllers/settings/general.py index 21ac8284..89254b53 100644 --- a/owrx/controllers/settings/general.py +++ b/owrx/controllers/settings/general.py @@ -158,6 +158,14 @@ class GeneralSettingsController(SettingsFormController): ), Section( "Map settings", + DropdownInput( + "map_type", + "Map type", + options=[ + Option("google", "Google Maps"), + Option("leaflet", "OpenStreetMap, etc."), + ], + ), TextInput( "google_maps_api_key", "Google Maps API key", diff --git a/owrx/controllers/template.py b/owrx/controllers/template.py index f7e1a530..118ee3f1 100644 --- a/owrx/controllers/template.py +++ b/owrx/controllers/template.py @@ -1,5 +1,6 @@ from owrx.controllers import Controller from owrx.details import ReceiverDetails +from owrx.config import Config from string import Template import pkg_resources @@ -25,7 +26,7 @@ class WebpageController(TemplateController): return "../" * levels def header_variables(self): - variables = {"document_root": self.get_document_root()} + variables = { "document_root": self.get_document_root(), "map_type": "" } variables.update(ReceiverDetails().__dict__()) return variables @@ -42,4 +43,27 @@ class IndexController(WebpageController): class MapController(WebpageController): def indexAction(self): # TODO check if we have a google maps api key first? - self.serve_template("map.html", **self.template_variables()) + # self.serve_template("map.html", **self.template_variables()) # AF: to be removed once the PR is accepted. + self.serve_template("map-{}.html".format(self.map_type()), **self.template_variables()) + + def header_variables(self): + # Invert map type for the "map" toolbar icon + variables = super().header_variables(); + type = self.map_type() + if type == "google": + variables.update({ "map_type" : "?type=leaflet" }) + elif type == "leaflet": + variables.update({ "map_type" : "?type=google" }) + return variables + + def map_type(self): + pm = Config.get() + if "type" not in self.request.query: + type = pm["map_type"] + else: + type = self.request.query["type"][0] + if type not in ["google", "leaflet"]: + type = pm["map_type"] + return type + +