Now showing latest FT8 and other calls with vectors on the map.

This commit is contained in:
Marat Fayzullin 2024-09-07 18:07:45 -04:00
parent 1a04f95d18
commit f7f72d50c8
15 changed files with 380 additions and 72 deletions

View File

@ -125,8 +125,7 @@ GSimpleMarker.prototype.setMarkerOptions = function(options) {
//
function GLocator() {
this.rect = new google.maps.Rectangle();
this.rect.setOptions({
this.rect = new google.maps.Rectangle({
strokeWeight : 0,
strokeColor : "#FFFFFF",
fillColor : "#FFFFFF",
@ -136,7 +135,7 @@ function GLocator() {
GLocator.prototype = new Locator();
GLocator.prototype.setMap = function(map) {
GLocator.prototype.setMap = function(map = null) {
this.rect.setMap(map);
};
@ -161,3 +160,48 @@ GLocator.prototype.setOpacity = function(opacity) {
fillOpacity : LocatorManager.fillOpacity * opacity
});
};
//
// GoogleMaps-Specific Call
//
function GCall() {
const dash = {
path : 'M 0,-1 0,1',
scale : 2,
strokeWeight : 1,
strokeOpacity : 0.5
};
this.line = new google.maps.Polyline({
geodesic : true,
strokeColor : '#000000',
strokeOpacity : 0,
strokeWeight : 0,
icons : [{ icon: dash, offset: 0, repeat: '8px' }]
});
}
GCall.prototype = new Call();
GCall.prototype.setMap = function(map = null) {
this.line.setMap(map);
};
GCall.prototype.setEnds = function(lat1, lon1, lat2, lon2) {
this.line.setOptions({ path : [
{ lat: lat1, lng: lon1 }, { lat: lat2, lng: lon2 }
]});
};
GCall.prototype.setColor = function(color) {
this.line.icons[0].icon.strokeColor = color;
this.line.setOptions({ icons: this.line.icons });
// this.line.setOptions({ strokeColor: color });
};
GCall.prototype.setOpacity = function(opacity) {
this.line.icons[0].icon.strokeOpacity = opacity;
this.line.setOptions({ icons: this.line.icons });
// this.line.setOptions({ strokeOpacity : opacity });
};

View File

@ -4,7 +4,7 @@
function LMarker () {
this._marker = L.marker();
};
}
LMarker.prototype.onAdd = function() {
this.div = this.create();
@ -24,9 +24,8 @@ LMarker.prototype.setMarkerOptions = function(options) {
}
};
LMarker.prototype.setMap = function (map) {
if (map) this._marker.addTo(map);
else this._marker.remove();
LMarker.prototype.setMap = function (map = null) {
if (map) this._marker.addTo(map); else this._marker.remove();
};
LMarker.prototype.addListener = function (e, f) {
@ -76,23 +75,26 @@ function LSimpleMarker() { $.extend(this, new LMarker(), new AprsMarker()); }
//
function LLocator() {
this._rect = L.rectangle([[0,0], [1,1]], { color: '#FFFFFF', weight: 0, fillOpacity: 1 });
this._rect = L.rectangle([[0,0], [1,1]], {
color : '#FFFFFF',
weight : 0,
fillOpacity : 1
});
}
LLocator.prototype = new Locator();
LLocator.prototype.setMap = function(map) {
if (map) this._rect.addTo(map);
else this._rect.remove();
LLocator.prototype.setMap = function(map = null) {
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 });
this._rect.setStyle({ color: color });
};
LLocator.prototype.setOpacity = function(opacity) {
@ -106,6 +108,39 @@ LLocator.prototype.addListener = function (e, f) {
this._rect.on(e, f);
};
//
// Leaflet-Specific Call
//
function LCall() {
this._line = L.polyline([[0, 0], [0, 0]], {
dashArray : [4, 4],
dashOffset : 0,
color : '#000000',
opacity : 0.5,
weight : 1
});
}
LCall.prototype = new Call();
LCall.prototype.setMap = function(map = null) {
if (map) this._line.addTo(map); else this._line.remove();
};
LCall.prototype.setEnds = function(lat1, lon1, lat2, lon2) {
this._line.setLatLngs([[lat1, lon1], [lat2, lon2]]);
};
LCall.prototype.setColor = function(color) {
this._line.setStyle({ color: color });
};
LCall.prototype.setOpacity = function(opacity) {
this._line.setStyle({ opacity: opacity });
};
//
// Position object
//
@ -119,5 +154,5 @@ function posObj(pos) {
this._lng = pos[1];
}
posObj.prototype.lat = function () { return this._lat; }
posObj.prototype.lng = function () { return this._lng; }
posObj.prototype.lat = function () { return this._lat; };
posObj.prototype.lng = function () { return this._lng; };

106
htdocs/lib/MapCalls.js Normal file
View File

@ -0,0 +1,106 @@
//
// Map Calls Management
//
CallManager.strokeOpacity = 0.5;
function CallManager() {
// Current calls
this.calls = [];
this.colorMode = 'band';
this.filterBy = null;
}
CallManager.prototype.add = function(call) {
// Remove excessive calls
while (this.calls.length > 0 && this.calls.length >= max_calls) {
var old = this.calls.shift();
old.setMap();
}
// Do not try adding if calls display disabled
if (max_calls <= 0) return false;
// Add new call
call.reColor(this.colorMode, this.filterBy);
this.calls.push(call);
return true;
};
CallManager.prototype.ageAll = function() {
var now = new Date().getTime();
var out = [];
this.calls.forEach((x) => { if (x.age(now)) out.push(x) });
this.calls = out;
};
CallManager.prototype.clear = function() {
// Remove all calls from the map
this.calls.forEach((x) => { x.setMap(); });
// Delete all calls
this.calls = [];
};
CallManager.prototype.setFilter = function(filterBy = null) {
this.filterBy = filterBy;
this.reColor();
};
CallManager.prototype.setColorMode = function(colorMode) {
// Clearing filter when color mode is changed
this.colorMode = colorMode;
this.setFilter();
};
CallManager.prototype.reColor = function() {
this.calls.forEach((x) => { x.reColor(this.colorMode, this.filterBy); });
};
//
// Generic Map Call
// Derived classes have to implement:
// setMap(), setEnds(), setColor(), setOpacity()
//
function Call() {}
Call.prototype.create = function(data, map) {
// Update call information
this.caller = data.caller;
this.callee = data.callee;
this.src = data.src;
this.dst = data.dst;
this.band = data.band;
this.mode = data.mode;
this.lastseen = data.lastseen;
// Make a call between two maidenhead squares
var src = Utils.loc2latlng(this.src.locator);
var dst = Utils.loc2latlng(this.dst.locator);
this.setEnds(src[0], src[1], dst[0], dst[1]);
// Place on the map
this.setMap(map);
// Age call
this.age(new Date().getTime());
}
Call.prototype.reColor = function(colorMode, filterBy = null) {
this.setOpacity(
colorMode==='off'? 0
: filterBy==null? CallManager.strokeOpacity
: colorMode==='band' && this.band==filterBy? CallManager.strokeOpacity
: colorMode==='mode' && this.mode==filterBy? CallManager.strokeOpacity
: 0
);
};
Call.prototype.age = function(now) {
if (now - this.lastseen > call_retention_time) {
this.setMap();
return false;
}
return true;
};

View File

@ -13,6 +13,7 @@ function LocatorManager(spectral = true) {
this.locators = {};
this.bands = {};
this.modes = {};
this.calls = [];
// The color scale used
this.colorScale = chroma.scale(colors).mode('hsl');
@ -64,7 +65,7 @@ LocatorManager.prototype.update = function(id, data, map) {
}
}
// Keep track modes
// Keep track of modes
if (!(data.mode in this.modes)) {
this.modes[data.mode] = '#000000';
this.assignColors(this.modes);
@ -161,6 +162,7 @@ LocatorManager.prototype.updateLegend = function() {
LocatorManager.prototype.setColorMode = function(newColorMode) {
$('#openwebrx-map-colormode').val(newColorMode);
LS.save('mapColorMode', newColorMode);
// Clearing filter when color mode is changed
this.colorMode = newColorMode;
this.setFilter();
};
@ -185,10 +187,8 @@ Locator.prototype.create = function(id) {
this.colorMode = 'band';
// Center locator at its maidenhead id
this.setCenter(
(id.charCodeAt(1) - 65 - 9) * 10 + Number(id[3]) + 0.5,
(id.charCodeAt(0) - 65 - 9) * 20 + Number(id[2]) * 2 + 1.0
);
var center = Utils.loc2latlng(id);
this.setCenter(center[0], center[1]);
}
Locator.prototype.update = function(data, map) {

View File

@ -21,10 +21,14 @@ function MapManager() {
// Locators management (FT8, FT4, WSPR, etc)
this.lman = new LocatorManager();
// Calls management (FT8, etc)
this.cman = new CallManager();
// Fade out / remove positions after time
setInterval(function() {
self.lman.ageAll();
self.mman.ageAll();
self.cman.ageAll();
}, 15000);
// When stuff loads...
@ -39,14 +43,21 @@ function MapManager() {
// Toggle color modes on click
$('#openwebrx-map-colormode').on('change', function() {
self.lman.setColorMode($(this).val());
var colorMode = $(this).val();
self.lman.setColorMode(colorMode);
self.cman.setColorMode(colorMode);
LS.save('mapColorMode', colorMode);
});
// Restore saved control settings
if (LS.has('openwebrx-map-selectors'))
self.toggleLegend(LS.loadBool('openwebrx-map-selectors'));
if (LS.has('mapColorMode'))
self.lman.setColorMode(LS.loadStr('mapColorMode'));
if (LS.has('mapColorMode')) {
var colorMode = LS.loadStr('mapColorMode');
self.lman.setColorMode(colorMode);
self.cman.setColorMode(colorMode);
$('#openwebrx-map-colormode').val(colorMode);
}
});
// Connect web socket
@ -98,6 +109,12 @@ MapManager.prototype.process = function(e) {
if ('map_position_retention_time' in this.config) {
retention_time = this.config.map_position_retention_time * 1000;
}
if ('map_call_retention_time' in this.config) {
call_retention_time = this.config.map_call_retention_time * 1000;
}
if ('map_max_calls' in this.config) {
max_calls = this.config.map_max_calls;
}
if ('callsign_url' in this.config) {
Utils.setCallsignUrl(this.config.callsign_url);
}
@ -140,6 +157,7 @@ MapManager.prototype.connect = function() {
self.removeReceiver();
self.mman.clear();
self.lman.clear();
self.cman.clear();
if (self.reconnect_timeout) {
// Max value: roundabout 8 and a half minutes
@ -183,12 +201,14 @@ MapManager.prototype.setupLegendFilters = function($legend) {
if ($lis.hasClass('disabled') && !$el.hasClass('disabled')) {
$lis.removeClass('disabled');
self.lman.setFilter();
self.cman.setFilter();
} else {
$el.removeClass('disabled');
$lis.filter(function() {
return this != $el[0]
}).addClass('disabled');
self.lman.setFilter($el.data('selector'));
self.cman.setFilter($el.data('selector'));
}
});

View File

@ -78,7 +78,7 @@ MarkerManager.prototype.toggle = function(map, type, onoff) {
// Show or hide features on the map
$.each(this.markers, function(_, x) {
if (x.mode === type) x.setMap(onoff ? map : undefined);
if (x.mode === type) x.setMap(onoff ? map : null);
});
};

View File

@ -239,6 +239,14 @@ Utils.mmsiIsGround = function(mmsi) {
return mmsi.substring(0, 2) === '00';
};
// Convert Maidenhead locator ID to lat/lon pair.
Utils.loc2latlng = function(id) {
return [
(id.charCodeAt(1) - 65 - 9) * 10 + Number(id[3]) + 0.5,
(id.charCodeAt(0) - 65 - 9) * 20 + Number(id[2]) * 2 + 1.0
];
};
// Save given canvas into a PNG file.
Utils.saveCanvas = function(canvas) {
// Get canvas by its ID

View File

@ -1,5 +1,7 @@
// reasonable default; will be overriden by server
// Reasonable defaults, will be overriden by server
var retention_time = 2 * 60 * 60 * 1000;
var call_retention_time = 15 * 60;
var max_calls = 5;
// Our Google Map
var map = null;
@ -146,6 +148,15 @@ MapManager.prototype.processUpdates = function(updates) {
}
updates.forEach(function(update) {
// Process caller-callee updates
if ('caller' in update) {
var call = new GCall();
call.create(update, map);
self.cman.add(call);
return;
}
// Process position updates
switch (update.location.type) {
case 'latlon':
var marker = self.mman.find(update.callsign);
@ -184,7 +195,7 @@ MapManager.prototype.processUpdates = function(updates) {
marker.update(update);
// Assign marker to map
marker.setMap(self.mman.isEnabled(update.mode)? map : undefined);
marker.setMap(self.mman.isEnabled(update.mode)? map : null);
// Apply marker options
if (marker instanceof GFeatureMarker) {

View File

@ -147,8 +147,10 @@ var mapExtraLayers = [
},
];
// reasonable default; will be overriden by server
// Reasonable defaults, will be overriden by server
var retention_time = 2 * 60 * 60 * 1000;
var call_retention_time = 15 * 60;
var max_calls = 5;
// Our Leaflet Map and layerControl
var map = null;
@ -433,6 +435,15 @@ MapManager.prototype.processUpdates = function(updates) {
}
updates.forEach(function(update) {
// Process caller-callee updates
if ('caller' in update) {
var call = new LCall();
call.create(update, map);
self.cman.add(call);
return;
}
// Process position updates
switch (update.location.type) {
case 'latlon':
var marker = self.mman.find(update.callsign);
@ -474,7 +485,7 @@ MapManager.prototype.processUpdates = function(updates) {
marker.update(update);
// Assign marker to map
marker.setMap(self.mman.isEnabled(update.mode)? map : undefined);
marker.setMap(self.mman.isEnabled(update.mode)? map : null);
// Apply marker options
if (marker instanceof LFeatureMarker) {

View File

@ -308,6 +308,8 @@ defaultConfig = PropertyLayer(
openweathermap_api_key="",
map_type="google",
map_position_retention_time=2 * 60 * 60,
map_call_retention_time=15 * 60,
map_max_calls=5,
map_prefer_recent_reports=True,
map_ignore_indirect_reports=False,
callsign_url="https://www.qrzcq.com/call/{}",

View File

@ -549,6 +549,8 @@ class MapConnection(OpenWebRxClient):
"map_position_retention_time",
"map_ignore_indirect_reports",
"map_prefer_recent_reports",
"map_call_retention_time",
"map_max_calls",
"callsign_url",
"vessel_url",
"flight_url",

View File

@ -151,6 +151,7 @@ class CompiledAssetsController(GzipMixin, ModificationAwareController):
"lib/jquery-3.2.1.min.js",
"lib/chroma.min.js",
"lib/Header.js",
"lib/MapCalls.js",
"lib/MapLocators.js",
"lib/MapMarkers.js",
"lib/MapManager.js",
@ -162,6 +163,7 @@ class CompiledAssetsController(GzipMixin, ModificationAwareController):
"lib/jquery-3.2.1.min.js",
"lib/chroma.min.js",
"lib/Header.js",
"lib/MapCalls.js",
"lib/MapLocators.js",
"lib/MapMarkers.js",
"lib/MapManager.js",

View File

@ -256,16 +256,29 @@ class GeneralSettingsController(SettingsFormController):
NumberInput(
"map_position_retention_time",
"Map retention time",
infotext="Specifies how long markers / grids will remain visible on the map",
infotext="Specifies how long markers / grids will remain visible on the map.",
append="s",
),
NumberInput(
"map_call_retention_time",
"Call retention time",
infotext="Specifies how long calls will remain visible on the map.",
validator=RangeValidator(15, 60*60),
append="s",
),
NumberInput(
"map_max_calls",
"Number of calls shown",
infotext="Specifies how many calls between grids are visible on the map.",
validator=RangeValidator(0, 50),
),
CheckboxInput(
"map_ignore_indirect_reports",
"Ignore position reports arriving via indirect path",
"Ignore position reports arriving via indirect path.",
),
CheckboxInput(
"map_prefer_recent_reports",
"Prefer more recent position reports to shorter path reports",
"Prefer more recent position reports to shorter path reports.",
),
TextInput(
"callsign_url",

View File

@ -36,6 +36,7 @@ class Map(object):
def __init__(self):
self.clients = []
self.positions = {}
self.calls = []
self.positionsLock = threading.Lock()
def removeLoop():
@ -66,16 +67,11 @@ class Map(object):
self.clients.append(client)
with self.positionsLock:
positions = [
{
"callsign": callsign,
"location": record["location"].__dict__(),
"lastseen": record["updated"].timestamp() * 1000,
"mode": record["mode"],
"band": record["band"].getName() if record["band"] is not None else None,
"hops": record["hops"],
}
for (callsign, record) in self.positions.items()
self._makeRecord(key, record) for (key, record) in self.positions.items()
] + [
self._makeCall(call) for call in self.calls
]
client.write_update(positions)
def removeClient(self, client):
@ -84,42 +80,90 @@ class Map(object):
except ValueError:
pass
def updateLocation(self, key, loc: Location, mode: str, band: Band = None, hops: list[str] = [], timestamp: datetime = None):
def _makeCall(self, call):
return {
"caller": call["caller"],
"callee": call["callee"],
"src": call["src"].__dict__(),
"dst": call["dst"].__dict__(),
"lastseen": call["timestamp"].timestamp() * 1000,
"mode": call["mode"],
"band": call["band"].getName() if call["band"] is not None else None
}
def _makeRecord(self, callsign, record):
return {
"callsign": callsign,
"location": record["location"].__dict__(),
"lastseen": record["updated"].timestamp() * 1000,
"mode": record["mode"],
"band": record["band"].getName() if record["band"] is not None else None,
"hops": record["hops"]
}
def updateCall(self, key, callee, mode: str, band: Band = None, timestamp: datetime = None):
# if we get an external timestamp, make sure it's not already expired
if timestamp is None:
timestamp = datetime.now(timezone.utc)
else:
# if we get an external timestamp, make sure it's not already expired
if datetime.now(timezone.utc) - loc.getTTL() > timestamp:
return
elif datetime.now(timezone.utc) - loc.getTTL() > timestamp:
return
max_calls = Config.get()["map_max_calls"]
broadcast = None
# update the list of callees for existing callsigns
with self.positionsLock:
if key in self.positions and callee in self.positions:
src = self.positions[key]["location"]
dst = self.positions[callee]["location"]
call = {
"caller": key,
"callee": callee,
"timestamp": timestamp,
"mode": mode,
"band": band,
"src": src,
"dst": dst
}
logger.debug("{0} call from {1} to {2}".format(mode, key, callee))
# remove excessive calls
while len(self.calls) > 0 and len(self.calls) >= max_calls:
self.calls.pop(0)
# add a new call
if len(self.calls) < max_calls:
broadcast = self._makeCall(call)
self.calls.append(call)
if broadcast is not None:
self.broadcast([broadcast])
def updateLocation(self, key, loc: Location, mode: str, band: Band = None, hops: list[str] = [], timestamp: datetime = None):
# if we get an external timestamp, make sure it's not already expired
if timestamp is None:
timestamp = datetime.now(timezone.utc)
elif datetime.now(timezone.utc) - loc.getTTL() > timestamp:
return
pm = Config.get()
ignoreIndirect = pm["map_ignore_indirect_reports"]
preferRecent = pm["map_prefer_recent_reports"]
needBroadcast = False
broadcast = None
with self.positionsLock:
# ignore indirect reports if ignoreIndirect set
if not ignoreIndirect or len(hops)==0:
# prefer messages with shorter hop count unless preferRecent set
if preferRecent or key not in self.positions or len(hops) <= len(self.positions[key]["hops"]):
if isinstance(loc, IncrementalUpdate) and key in self.positions:
# ignore indirect reports if ignoreIndirect set
if not ignoreIndirect or len(hops)==0:
# prefer messages with shorter hop count unless preferRecent set
with self.positionsLock:
if key not in self.positions:
self.positions[key] = { "location": loc, "updated": timestamp, "mode": mode, "band": band, "hops": hops }
broadcast = self._makeRecord(key, self.positions[key])
elif preferRecent or len(hops) <= len(self.positions[key]["hops"]):
if isinstance(loc, IncrementalUpdate):
loc.update(self.positions[key]["location"])
self.positions[key] = {"location": loc, "updated": timestamp, "mode": mode, "band": band, "hops": hops }
needBroadcast = True
self.positions[key].update({ "location": loc, "updated": timestamp, "mode": mode, "band": band, "hops": hops })
broadcast = self._makeRecord(key, self.positions[key])
if needBroadcast:
self.broadcast(
[
{
"callsign": key,
"location": loc.__dict__(),
"lastseen": timestamp.timestamp() * 1000,
"mode": mode,
"band": band.getName() if band is not None else None,
"hops": hops,
}
]
)
if broadcast is not None:
self.broadcast([broadcast])
def touchLocation(self, key):
# not implemented on the client side yet, so do not use!

View File

@ -292,6 +292,10 @@ class WsjtParser(AudioChopperParser):
out["callsign"], LocatorLocation(out["locator"]), mode, band
)
ReportingEngine.getSharedInstance().spot(out)
if "callsign" in out and "callee" in out:
Map.getSharedInstance().updateCall(
out["callsign"], out["callee"], mode, band
)
return out
except Exception:
@ -344,17 +348,23 @@ class MessageParser(ABC):
# Used in QSO-style modes (FT8, FT4, FST4)
class QsoMessageParser(MessageParser):
locator_pattern = re.compile(".*\\s([A-Z0-9/]{2,})(\\sR)?\\s([A-R]{2}[0-9]{2})$")
locator_pattern = re.compile("^(.*)\\s([A-Z0-9/]{2,})(\\sR)?\\s(([A-R]{2}[0-9]{2})|73|RRR)$")
calee_pattern = re.compile("^([A-Z0-9/]{2,})(\\s.*)?$")
def parse(self, msg):
m = QsoMessageParser.locator_pattern.match(msg)
if m is None:
return {}
# this is a valid locator in theory, but it's somewhere in the arctic ocean, near the north pole, so it's very
out = {"callsign": m.group(2)}
# RR73 is a valid locator in theory, but it's somewhere in the arctic ocean, near the north pole, so it's very
# likely this just means roger roger goodbye.
if m.group(3) == "RR73":
return {"callsign": m.group(1)}
return {"callsign": m.group(1), "locator": m.group(3)}
if m.group(4) not in ["RR73", "73", "RRR"]:
out["locator"] = m.group(4)
else:
m = QsoMessageParser.calee_pattern.match(m.group(1))
if m is not None:
out["callee"] = m.group(1)
return out
# Used in propagation reporting / beacon modes (WSPR / FST4W)