mirror of
https://git.ianrenton.com/ian/spothole.git
synced 2025-10-27 08:49:27 +00:00
Compare commits
27 Commits
229228d209
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
3ea782579b | ||
|
|
8b036ddb46 | ||
|
|
3f827c597b | ||
|
|
587d3b4cf1 | ||
|
|
6eb1bd5ef1 | ||
|
|
0ead59a985 | ||
|
|
82b3c262b6 | ||
|
|
80b5077496 | ||
|
|
3625998f46 | ||
|
|
e31c750b41 | ||
|
|
ab05824c5d | ||
|
|
bb7b6d6f3c | ||
|
|
2c8d18685c | ||
|
|
090310240f | ||
|
|
f2f03b135f | ||
|
|
5d4b3d500d | ||
|
|
65d83d2339 | ||
|
|
5093a8d3d1 | ||
|
|
bdd31f6993 | ||
|
|
1bad16f478 | ||
|
|
ae8be4446c | ||
|
|
3515fbd5c7 | ||
|
|
f5e50dc5b4 | ||
|
|
001ec2c9b9 | ||
|
|
be86160e9c | ||
|
|
0b3b35db35 | ||
|
|
6e9bab5eee |
@@ -14,6 +14,8 @@ Supported data sources include DX Clusters, the Reverse Beacon Network (RBN), th
|
|||||||
|
|
||||||

|

|
||||||
|
|
||||||
|

|
||||||
|
|
||||||
### Accessing the public version
|
### Accessing the public version
|
||||||
|
|
||||||
You can access the public version's web interface at [https://spothole.app](https://spothole.app), and see [https://spothole.app/apidocs](https://spothole.app/apidocs) for the API details.
|
You can access the public version's web interface at [https://spothole.app](https://spothole.app), and see [https://spothole.app/apidocs](https://spothole.app/apidocs) for the API details.
|
||||||
|
|||||||
@@ -49,7 +49,8 @@ class ParksNPeaks(HTTPAlertProvider):
|
|||||||
logging.warn("PNP alert found with sig " + alert.sig + ", developer needs to add support for this!")
|
logging.warn("PNP alert found with sig " + alert.sig + ", developer needs to add support for this!")
|
||||||
|
|
||||||
# If this is POTA, SOTA or WWFF data we already have it through other means, so ignore. Otherwise, add to
|
# If this is POTA, SOTA or WWFF data we already have it through other means, so ignore. Otherwise, add to
|
||||||
# the alert list.
|
# the alert list. Note that while ZLOTA has its own spots API, it doesn't have its own alerts API. So that
|
||||||
|
# means the PnP *spot* provider rejects ZLOTA spots here, but the PnP *alerts* provider here allows ZLOTA.
|
||||||
if alert.sig not in ["POTA", "SOTA", "WWFF"]:
|
if alert.sig not in ["POTA", "SOTA", "WWFF"]:
|
||||||
new_alerts.append(alert)
|
new_alerts.append(alert)
|
||||||
return new_alerts
|
return new_alerts
|
||||||
|
|||||||
64
alertproviders/wota.py
Normal file
64
alertproviders/wota.py
Normal file
@@ -0,0 +1,64 @@
|
|||||||
|
from datetime import datetime
|
||||||
|
|
||||||
|
import pytz
|
||||||
|
from rss_parser import RSSParser
|
||||||
|
|
||||||
|
from alertproviders.http_alert_provider import HTTPAlertProvider
|
||||||
|
from core.sig_utils import get_icon_for_sig
|
||||||
|
from data.alert import Alert
|
||||||
|
|
||||||
|
|
||||||
|
# Alert provider for Wainwrights on the Air
|
||||||
|
class WOTA(HTTPAlertProvider):
|
||||||
|
POLL_INTERVAL_SEC = 3600
|
||||||
|
ALERTS_URL = "https://www.wota.org.uk/alerts_rss.php"
|
||||||
|
RSS_DATE_TIME_FORMAT = "%a, %d %b %Y %H:%M:%S %z"
|
||||||
|
|
||||||
|
def __init__(self, provider_config):
|
||||||
|
super().__init__(provider_config, self.ALERTS_URL, self.POLL_INTERVAL_SEC)
|
||||||
|
|
||||||
|
def http_response_to_alerts(self, http_response):
|
||||||
|
new_alerts = []
|
||||||
|
rss = RSSParser.parse(http_response.content.decode())
|
||||||
|
# Iterate through source data
|
||||||
|
for source_alert in rss.channel.items:
|
||||||
|
|
||||||
|
# Reject GUID missing or zero
|
||||||
|
if not source_alert.guid or not source_alert.guid.content or source_alert.guid.content == "http://www.wota.org.uk/alerts/0":
|
||||||
|
continue
|
||||||
|
|
||||||
|
# Pick apart the title
|
||||||
|
title_split = source_alert.title.split(" on ")
|
||||||
|
dx_call = title_split[0]
|
||||||
|
ref = None
|
||||||
|
ref_name = None
|
||||||
|
if len(title_split) > 1:
|
||||||
|
ref_split = title_split[1].split(" - ")
|
||||||
|
ref = ref_split[0]
|
||||||
|
if len(ref_split) > 1:
|
||||||
|
ref_name = ref_split[1]
|
||||||
|
|
||||||
|
# Pick apart the description
|
||||||
|
desc_split = source_alert.description.split(". ")
|
||||||
|
freqs_modes = desc_split[0].replace("Frequencies/modes:", "").strip()
|
||||||
|
comment = None
|
||||||
|
if len(desc_split) > 1:
|
||||||
|
comment = desc_split[1].strip()
|
||||||
|
|
||||||
|
time = datetime.strptime(source_alert.pub_date.content, self.RSS_DATE_TIME_FORMAT).astimezone(pytz.UTC)
|
||||||
|
|
||||||
|
# Convert to our alert format
|
||||||
|
alert = Alert(source=self.name,
|
||||||
|
source_id=source_alert.guid.content,
|
||||||
|
dx_calls=[dx_call],
|
||||||
|
freqs_modes=freqs_modes,
|
||||||
|
comment=comment,
|
||||||
|
sig="WOTA",
|
||||||
|
sig_refs=[ref] if ref else [],
|
||||||
|
sig_refs_names=[ref_name] if ref_name else [],
|
||||||
|
icon=get_icon_for_sig("WOTA"),
|
||||||
|
start_time=time.timestamp())
|
||||||
|
|
||||||
|
# Add to our list.
|
||||||
|
new_alerts.append(alert)
|
||||||
|
return new_alerts
|
||||||
@@ -41,6 +41,14 @@ spot-providers:
|
|||||||
class: "ParksNPeaks"
|
class: "ParksNPeaks"
|
||||||
name: "ParksNPeaks"
|
name: "ParksNPeaks"
|
||||||
enabled: true
|
enabled: true
|
||||||
|
-
|
||||||
|
class: "ZLOTA"
|
||||||
|
name: "ZLOTA"
|
||||||
|
enabled: true
|
||||||
|
-
|
||||||
|
class: "WOTA"
|
||||||
|
name: "WOTA"
|
||||||
|
enabled: true
|
||||||
-
|
-
|
||||||
class: "APRSIS"
|
class: "APRSIS"
|
||||||
name: "APRS-IS"
|
name: "APRS-IS"
|
||||||
@@ -88,6 +96,10 @@ alert-providers:
|
|||||||
class: "ParksNPeaks"
|
class: "ParksNPeaks"
|
||||||
name: "ParksNPeaks"
|
name: "ParksNPeaks"
|
||||||
enabled: true
|
enabled: true
|
||||||
|
-
|
||||||
|
class: "WOTA"
|
||||||
|
name: "WOTA"
|
||||||
|
enabled: true
|
||||||
-
|
-
|
||||||
class: "NG3K"
|
class: "NG3K"
|
||||||
name: "NG3K"
|
name: "NG3K"
|
||||||
|
|||||||
@@ -26,13 +26,14 @@ SIGS = [
|
|||||||
SIG(name="ZLOTA", description="New Zealand on the Air", icon="kiwi-bird", ref_regex=r"ZL[A-Z]/[A-Z]{2}\-\d+"),
|
SIG(name="ZLOTA", description="New Zealand on the Air", icon="kiwi-bird", ref_regex=r"ZL[A-Z]/[A-Z]{2}\-\d+"),
|
||||||
SIG(name="KRMNPA", description="Keith Roget Memorial National Parks Award", icon="earth-oceania", ref_regex=r""),
|
SIG(name="KRMNPA", description="Keith Roget Memorial National Parks Award", icon="earth-oceania", ref_regex=r""),
|
||||||
SIG(name="WAB", description="Worked All Britain", icon="table-cells-large", ref_regex=r"[A-Z]{1,2}[0-9]{2}"),
|
SIG(name="WAB", description="Worked All Britain", icon="table-cells-large", ref_regex=r"[A-Z]{1,2}[0-9]{2}"),
|
||||||
SIG(name="WAI", description="Worked All Ireland", icon="table-cells-large", ref_regex=r"[A-Z][0-9]{2}")
|
SIG(name="WAI", description="Worked All Ireland", icon="table-cells-large", ref_regex=r"[A-Z][0-9]{2}"),
|
||||||
|
SIG(name="WOTA", description="Wainwrights on the Air", icon="w", ref_regex=r"[A-Z]{3}-[0-9]{2}")
|
||||||
]
|
]
|
||||||
|
|
||||||
# Modes. Note "DIGI" and "DIGITAL" are also supported but are normalised into "DATA".
|
# Modes. Note "DIGI" and "DIGITAL" are also supported but are normalised into "DATA".
|
||||||
CW_MODES = ["CW"]
|
CW_MODES = ["CW"]
|
||||||
PHONE_MODES = ["PHONE", "SSB", "USB", "LSB", "AM", "FM", "DV", "DMR", "DSTAR", "C4FM", "M17"]
|
PHONE_MODES = ["PHONE", "SSB", "USB", "LSB", "AM", "FM", "DV", "DMR", "DSTAR", "C4FM", "M17"]
|
||||||
DATA_MODES = ["DATA", "FT8", "FT4", "RTTY", "SSTV", "JS8", "HELL", "BPSK", "PSK", "PSK31", "BPSK31", "OLIVIA"]
|
DATA_MODES = ["DATA", "FT8", "FT4", "RTTY", "SSTV", "JS8", "HELL", "BPSK", "PSK", "PSK31", "BPSK31", "OLIVIA", "MFSK", "MFSK32", "PKT"]
|
||||||
ALL_MODES = CW_MODES + PHONE_MODES + DATA_MODES
|
ALL_MODES = CW_MODES + PHONE_MODES + DATA_MODES
|
||||||
MODE_TYPES = ["CW", "PHONE", "DATA"]
|
MODE_TYPES = ["CW", "PHONE", "DATA"]
|
||||||
|
|
||||||
|
|||||||
@@ -3,7 +3,7 @@ import logging
|
|||||||
from datetime import timedelta
|
from datetime import timedelta
|
||||||
|
|
||||||
from diskcache import Cache
|
from diskcache import Cache
|
||||||
from pyhamtools import LookupLib, Callinfo
|
from pyhamtools import LookupLib, Callinfo, callinfo
|
||||||
from pyhamtools.exceptions import APIKeyMissingError
|
from pyhamtools.exceptions import APIKeyMissingError
|
||||||
from pyhamtools.frequency import freq_to_band
|
from pyhamtools.frequency import freq_to_band
|
||||||
from pyhamtools.locator import latlong_to_locator
|
from pyhamtools.locator import latlong_to_locator
|
||||||
@@ -266,14 +266,19 @@ class LookupHelper:
|
|||||||
# Utility method to get QRZ.com data from cache if possible, if not get it from the API and cache it
|
# Utility method to get QRZ.com data from cache if possible, if not get it from the API and cache it
|
||||||
def get_qrz_data_for_callsign(self, call):
|
def get_qrz_data_for_callsign(self, call):
|
||||||
# Fetch from cache if we can, otherwise fetch from the API and cache it
|
# Fetch from cache if we can, otherwise fetch from the API and cache it
|
||||||
qrz_data = self.QRZ_CALLSIGN_DATA_CACHE.get(call)
|
if call in self.QRZ_CALLSIGN_DATA_CACHE:
|
||||||
if qrz_data:
|
return self.QRZ_CALLSIGN_DATA_CACHE.get(call)
|
||||||
return qrz_data
|
|
||||||
elif self.QRZ_AVAILABLE:
|
elif self.QRZ_AVAILABLE:
|
||||||
try:
|
try:
|
||||||
data = self.LOOKUP_LIB_QRZ.lookup_callsign(callsign=call)
|
data = self.LOOKUP_LIB_QRZ.lookup_callsign(callsign=call)
|
||||||
self.QRZ_CALLSIGN_DATA_CACHE.add(call, data, expire=604800) # 1 week in seconds
|
self.QRZ_CALLSIGN_DATA_CACHE.add(call, data, expire=604800) # 1 week in seconds
|
||||||
return data
|
return data
|
||||||
|
except KeyError:
|
||||||
|
# QRZ had no info for the call, but maybe it had prefixes or suffixes. Try again with the base call.
|
||||||
|
try:
|
||||||
|
data = self.LOOKUP_LIB_QRZ.lookup_callsign(callsign=callinfo.Callinfo.get_homecall(call))
|
||||||
|
self.QRZ_CALLSIGN_DATA_CACHE.add(call, data, expire=604800) # 1 week in seconds
|
||||||
|
return data
|
||||||
except KeyError:
|
except KeyError:
|
||||||
# QRZ had no info for the call, that's OK. Cache a None so we don't try to look this up again
|
# QRZ had no info for the call, that's OK. Cache a None so we don't try to look this up again
|
||||||
self.QRZ_CALLSIGN_DATA_CACHE.add(call, None, expire=604800) # 1 week in seconds
|
self.QRZ_CALLSIGN_DATA_CACHE.add(call, None, expire=604800) # 1 week in seconds
|
||||||
@@ -284,14 +289,19 @@ class LookupHelper:
|
|||||||
# Utility method to get Clublog API data from cache if possible, if not get it from the API and cache it
|
# Utility method to get Clublog API data from cache if possible, if not get it from the API and cache it
|
||||||
def get_clublog_api_data_for_callsign(self, call):
|
def get_clublog_api_data_for_callsign(self, call):
|
||||||
# Fetch from cache if we can, otherwise fetch from the API and cache it
|
# Fetch from cache if we can, otherwise fetch from the API and cache it
|
||||||
clublog_data = self.CLUBLOG_CALLSIGN_DATA_CACHE.get(call)
|
if call in self.CLUBLOG_CALLSIGN_DATA_CACHE:
|
||||||
if clublog_data:
|
return self.CLUBLOG_CALLSIGN_DATA_CACHE.get(call)
|
||||||
return clublog_data
|
|
||||||
elif self.CLUBLOG_API_AVAILABLE:
|
elif self.CLUBLOG_API_AVAILABLE:
|
||||||
try:
|
try:
|
||||||
data = self.LOOKUP_LIB_CLUBLOG_API.lookup_callsign(callsign=call)
|
data = self.LOOKUP_LIB_CLUBLOG_API.lookup_callsign(callsign=call)
|
||||||
self.CLUBLOG_CALLSIGN_DATA_CACHE.add(call, data, expire=604800) # 1 week in seconds
|
self.CLUBLOG_CALLSIGN_DATA_CACHE.add(call, data, expire=604800) # 1 week in seconds
|
||||||
return data
|
return data
|
||||||
|
except KeyError:
|
||||||
|
# Clublog had no info for the call, but maybe it had prefixes or suffixes. Try again with the base call.
|
||||||
|
try:
|
||||||
|
data = self.LOOKUP_LIB_CLUBLOG_API.lookup_callsign(callsign=callinfo.Callinfo.get_homecall(call))
|
||||||
|
self.CLUBLOG_CALLSIGN_DATA_CACHE.add(call, data, expire=604800) # 1 week in seconds
|
||||||
|
return data
|
||||||
except KeyError:
|
except KeyError:
|
||||||
# Clublog had no info for the call, that's OK. Cache a None so we don't try to look this up again
|
# Clublog had no info for the call, that's OK. Cache a None so we don't try to look this up again
|
||||||
self.CLUBLOG_CALLSIGN_DATA_CACHE.add(call, None, expire=604800) # 1 week in seconds
|
self.CLUBLOG_CALLSIGN_DATA_CACHE.add(call, None, expire=604800) # 1 week in seconds
|
||||||
|
|||||||
@@ -111,11 +111,6 @@ class Alert:
|
|||||||
if self.dx_calls and not self.dx_names:
|
if self.dx_calls and not self.dx_names:
|
||||||
self.dx_names = list(map(lambda c: lookup_helper.infer_name_from_callsign(c), self.dx_calls))
|
self.dx_names = list(map(lambda c: lookup_helper.infer_name_from_callsign(c), self.dx_calls))
|
||||||
|
|
||||||
# Clean up comments
|
|
||||||
if self.comment:
|
|
||||||
comment = re.sub(r"\(de [A-Za-z0-9]*\)", "", self.comment)
|
|
||||||
self.comment = comment.strip()
|
|
||||||
|
|
||||||
# Always create an ID based on a hash of every parameter *except* received_time. This is used as the index
|
# Always create an ID based on a hash of every parameter *except* received_time. This is used as the index
|
||||||
# to a map, which as a byproduct avoids us having multiple duplicate copies of the object that are identical
|
# to a map, which as a byproduct avoids us having multiple duplicate copies of the object that are identical
|
||||||
# apart from that they were retrieved from the API at different times. Note that the simple Python hash()
|
# apart from that they were retrieved from the API at different times. Note that the simple Python hash()
|
||||||
|
|||||||
26
data/spot.py
26
data/spot.py
@@ -177,6 +177,20 @@ class Spot:
|
|||||||
if self.de_call and "-" in self.de_call:
|
if self.de_call and "-" in self.de_call:
|
||||||
self.de_call = self.de_call.split("-")[0]
|
self.de_call = self.de_call.split("-")[0]
|
||||||
|
|
||||||
|
# If we have a spotter of "RBNHOLE", we should have the actual spotter callsign in the comment, so extract it.
|
||||||
|
# RBNHole posts come from a number of providers, so it's dealt with here in the generic spot handling code.
|
||||||
|
if self.de_call == "RBNHOLE" and self.comment:
|
||||||
|
rbnhole_call_match = re.search(r"\Wat ([a-z0-9/]+)\W", self.comment, re.IGNORECASE)
|
||||||
|
if rbnhole_call_match:
|
||||||
|
self.de_call = rbnhole_call_match.group(1).upper()
|
||||||
|
|
||||||
|
# If we have a spotter of "SOTAMAT", we might have the actual spotter callsign in the comment, if so extract it.
|
||||||
|
# SOTAMAT can do POTA as well as SOTA, so it's dealt with here in the generic spot handling code.
|
||||||
|
if self.de_call == "SOTAMAT" and self.comment:
|
||||||
|
sotamat_call_match = re.search(r"\Wfrom ([a-z0-9/]+)]", self.comment, re.IGNORECASE)
|
||||||
|
if sotamat_call_match:
|
||||||
|
self.de_call = sotamat_call_match.group(1).upper()
|
||||||
|
|
||||||
# Spotter country, continent, zones etc. from callsign.
|
# Spotter country, continent, zones etc. from callsign.
|
||||||
# DE of "RBNHOLE" and "SOTAMAT" are not things we can look up location for
|
# DE of "RBNHOLE" and "SOTAMAT" are not things we can look up location for
|
||||||
if self.de_call != "RBNHOLE" and self.de_call != "SOTAMAT":
|
if self.de_call != "RBNHOLE" and self.de_call != "SOTAMAT":
|
||||||
@@ -249,14 +263,6 @@ class Spot:
|
|||||||
if self.comment and not self.qrt:
|
if self.comment and not self.qrt:
|
||||||
self.qrt = "QRT" in self.comment.upper()
|
self.qrt = "QRT" in self.comment.upper()
|
||||||
|
|
||||||
# Clean up comments
|
|
||||||
if self.comment:
|
|
||||||
comment = re.sub(r"\(de [A-Za-z0-9]*\)", "", self.comment)
|
|
||||||
comment = re.sub(r"\[.*]:", "", comment)
|
|
||||||
comment = re.sub(r"\[.*]", "", comment)
|
|
||||||
comment = re.sub(r"\"\"", "", comment)
|
|
||||||
self.comment = comment.strip()
|
|
||||||
|
|
||||||
# DX operator details lookup, using QRZ.com. This should be the last resort compared to taking the data from
|
# DX operator details lookup, using QRZ.com. This should be the last resort compared to taking the data from
|
||||||
# the actual spotting service, e.g. we don't want to accidentally use a user's QRZ.com home lat/lon instead of
|
# the actual spotting service, e.g. we don't want to accidentally use a user's QRZ.com home lat/lon instead of
|
||||||
# the one from the park reference they're at.
|
# the one from the park reference they're at.
|
||||||
@@ -284,8 +290,8 @@ class Spot:
|
|||||||
self.dx_location_good = self.dx_location_source == "SPOT" or self.dx_location_source == "WAB/WAI GRID" or (
|
self.dx_location_good = self.dx_location_source == "SPOT" or self.dx_location_source == "WAB/WAI GRID" or (
|
||||||
self.dx_location_source == "QRZ" and not "/" in self.dx_call)
|
self.dx_location_source == "QRZ" and not "/" in self.dx_call)
|
||||||
|
|
||||||
# DE of "RBNHOLE", "SOTAMAT" and "ZLOTA" are not things we can look up location for
|
# DE of "RBNHOLE" and "SOTAMAT" are not things we can look up location for
|
||||||
if self.de_call != "RBNHOLE" and self.de_call != "SOTAMAT" and self.de_call != "ZLOTA":
|
if self.de_call != "RBNHOLE" and self.de_call != "SOTAMAT":
|
||||||
# DE operator position lookup, using QRZ.com.
|
# DE operator position lookup, using QRZ.com.
|
||||||
if self.de_call and not self.de_latitude:
|
if self.de_call and not self.de_latitude:
|
||||||
latlon = lookup_helper.infer_latlon_from_callsign_qrz(self.de_call)
|
latlon = lookup_helper.infer_latlon_from_callsign_qrz(self.de_call)
|
||||||
|
|||||||
BIN
images/screenshot3.png
Normal file
BIN
images/screenshot3.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 173 KiB |
@@ -19,9 +19,9 @@ class DXCluster(SpotProvider):
|
|||||||
# Note the callsign pattern deliberately excludes calls ending in "-#", which are from RBN and can be enabled by
|
# Note the callsign pattern deliberately excludes calls ending in "-#", which are from RBN and can be enabled by
|
||||||
# default on some clusters. If you want RBN spots, there is a separate provider for that.
|
# default on some clusters. If you want RBN spots, there is a separate provider for that.
|
||||||
CALLSIGN_PATTERN = "([a-z|0-9|/]+)"
|
CALLSIGN_PATTERN = "([a-z|0-9|/]+)"
|
||||||
FREQUENCY_PATTERM = "([0-9|.]+)"
|
FREQUENCY_PATTERN = "([0-9|.]+)"
|
||||||
LINE_PATTERN = re.compile(
|
LINE_PATTERN = re.compile(
|
||||||
"^DX de " + CALLSIGN_PATTERN + ":\\s+" + FREQUENCY_PATTERM + "\\s+" + CALLSIGN_PATTERN + "\\s+(.*)\\s+(\\d{4}Z)",
|
"^DX de " + CALLSIGN_PATTERN + ":\\s+" + FREQUENCY_PATTERN + "\\s+" + CALLSIGN_PATTERN + "\\s+(.*)\\s+(\\d{4}Z)",
|
||||||
re.IGNORECASE)
|
re.IGNORECASE)
|
||||||
|
|
||||||
# Constructor requires hostname and port
|
# Constructor requires hostname and port
|
||||||
|
|||||||
@@ -19,9 +19,6 @@ class ParksNPeaks(HTTPSpotProvider):
|
|||||||
SIOTA_LIST_URL = "https://www.silosontheair.com/data/silos.csv"
|
SIOTA_LIST_URL = "https://www.silosontheair.com/data/silos.csv"
|
||||||
SIOTA_LIST_CACHE_TIME_DAYS = 30
|
SIOTA_LIST_CACHE_TIME_DAYS = 30
|
||||||
SIOTA_LIST_CACHE = CachedSession("cache/siota_data_cache", expire_after=timedelta(days=SIOTA_LIST_CACHE_TIME_DAYS))
|
SIOTA_LIST_CACHE = CachedSession("cache/siota_data_cache", expire_after=timedelta(days=SIOTA_LIST_CACHE_TIME_DAYS))
|
||||||
ZLOTA_LIST_URL = "https://ontheair.nz/assets/assets.json"
|
|
||||||
ZLOTA_LIST_CACHE_TIME_DAYS = 30
|
|
||||||
ZLOTA_LIST_CACHE = CachedSession("cache/zlota_data_cache", expire_after=timedelta(days=ZLOTA_LIST_CACHE_TIME_DAYS))
|
|
||||||
|
|
||||||
def __init__(self, provider_config):
|
def __init__(self, provider_config):
|
||||||
super().__init__(provider_config, self.SPOTS_URL, self.POLL_INTERVAL_SEC)
|
super().__init__(provider_config, self.SPOTS_URL, self.POLL_INTERVAL_SEC)
|
||||||
@@ -52,7 +49,7 @@ class ParksNPeaks(HTTPSpotProvider):
|
|||||||
|
|
||||||
# Extract a de_call if it's in the comment but not in the "actSpoter" field
|
# Extract a de_call if it's in the comment but not in the "actSpoter" field
|
||||||
m = re.search(r"\(de ([A-Za-z0-9]*)\)", spot.comment)
|
m = re.search(r"\(de ([A-Za-z0-9]*)\)", spot.comment)
|
||||||
if (not spot.de_call or spot.de_call == "ZLOTA") and m:
|
if not spot.de_call and m:
|
||||||
spot.de_call = m.group(1)
|
spot.de_call = m.group(1)
|
||||||
|
|
||||||
# Log a warning for the developer if PnP gives us an unknown programme we've never seen before
|
# Log a warning for the developer if PnP gives us an unknown programme we've never seen before
|
||||||
@@ -70,20 +67,10 @@ class ParksNPeaks(HTTPSpotProvider):
|
|||||||
spot.dx_grid = row["LOCATOR"]
|
spot.dx_grid = row["LOCATOR"]
|
||||||
break
|
break
|
||||||
|
|
||||||
# ZLOTA name/lat/lon lookup
|
|
||||||
if spot.sig == "ZLOTA":
|
|
||||||
zlota_data = self.ZLOTA_LIST_CACHE.get(self.ZLOTA_LIST_URL, headers=HTTP_HEADERS).json()
|
|
||||||
for asset in zlota_data:
|
|
||||||
if asset["code"] == spot.sig_refs[0]:
|
|
||||||
spot.sig_refs_names = [asset["name"]]
|
|
||||||
spot.dx_latitude = asset["y"]
|
|
||||||
spot.dx_longitude = asset["x"]
|
|
||||||
break
|
|
||||||
|
|
||||||
# Note there is currently no support for KRMNPA location lookup, see issue #61.
|
# Note there is currently no support for KRMNPA location lookup, see issue #61.
|
||||||
|
|
||||||
# If this is POTA, SOTA or WWFF data we already have it through other means, so ignore. Otherwise, add to
|
# If this is POTA, SOTA, WWFF or ZLOTA data we already have it through other means, so ignore. Otherwise,
|
||||||
# the spot list.
|
# add to the spot list.
|
||||||
if spot.sig not in ["POTA", "SOTA", "WWFF"]:
|
if spot.sig not in ["POTA", "SOTA", "WWFF", "ZLOTA"]:
|
||||||
new_spots.append(spot)
|
new_spots.append(spot)
|
||||||
return new_spots
|
return new_spots
|
||||||
|
|||||||
88
spotproviders/wota.py
Normal file
88
spotproviders/wota.py
Normal file
@@ -0,0 +1,88 @@
|
|||||||
|
from datetime import timedelta, datetime
|
||||||
|
|
||||||
|
import pytz
|
||||||
|
from requests_cache import CachedSession
|
||||||
|
from rss_parser import RSSParser
|
||||||
|
|
||||||
|
from core.constants import HTTP_HEADERS
|
||||||
|
from core.sig_utils import get_icon_for_sig
|
||||||
|
from data.spot import Spot
|
||||||
|
from spotproviders.http_spot_provider import HTTPSpotProvider
|
||||||
|
|
||||||
|
|
||||||
|
# Spot provider for Wainwrights on the Air
|
||||||
|
class WOTA(HTTPSpotProvider):
|
||||||
|
POLL_INTERVAL_SEC = 120
|
||||||
|
SPOTS_URL = "https://www.wota.org.uk/spots_rss.php"
|
||||||
|
LIST_URL = "https://www.wota.org.uk/mapping/data/summits.json"
|
||||||
|
LIST_CACHE_TIME_DAYS = 30
|
||||||
|
LIST_CACHE = CachedSession("cache/wota_data_cache", expire_after=timedelta(days=LIST_CACHE_TIME_DAYS))
|
||||||
|
RSS_DATE_TIME_FORMAT = "%a, %d %b %Y %H:%M:%S %z"
|
||||||
|
|
||||||
|
def __init__(self, provider_config):
|
||||||
|
super().__init__(provider_config, self.SPOTS_URL, self.POLL_INTERVAL_SEC)
|
||||||
|
|
||||||
|
def http_response_to_spots(self, http_response):
|
||||||
|
new_spots = []
|
||||||
|
rss = RSSParser.parse(http_response.content.decode())
|
||||||
|
# Iterate through source data
|
||||||
|
for source_spot in rss.channel.items:
|
||||||
|
|
||||||
|
# Reject GUID missing or zero
|
||||||
|
if not source_spot.guid or not source_spot.guid.content or source_spot.guid.content == "http://www.wota.org.uk/spots/0":
|
||||||
|
continue
|
||||||
|
|
||||||
|
# Pick apart the title
|
||||||
|
title_split = source_spot.title.split(" on ")
|
||||||
|
dx_call = title_split[0]
|
||||||
|
ref = None
|
||||||
|
ref_name = None
|
||||||
|
if len(title_split) > 1:
|
||||||
|
ref_split = title_split[1].split(" - ")
|
||||||
|
ref = ref_split[0]
|
||||||
|
if len(ref_split) > 1:
|
||||||
|
ref_name = ref_split[1]
|
||||||
|
|
||||||
|
# Pick apart the description
|
||||||
|
desc_split = source_spot.description.split(". ")
|
||||||
|
freq_mode = desc_split[0].replace("Frequencies/modes:", "").strip()
|
||||||
|
freq_mode_split = freq_mode.split("-")
|
||||||
|
freq_hz = float(freq_mode_split[0]) * 1000000
|
||||||
|
mode = freq_mode_split[1]
|
||||||
|
|
||||||
|
comment = None
|
||||||
|
if len(desc_split) > 1:
|
||||||
|
comment = desc_split[1].strip()
|
||||||
|
spotter = None
|
||||||
|
if len(desc_split) > 2:
|
||||||
|
spotter = desc_split[2].replace("Spotted by ", "").replace(".", "").strip()
|
||||||
|
|
||||||
|
time = datetime.strptime(source_spot.pub_date.content, self.RSS_DATE_TIME_FORMAT).astimezone(pytz.UTC)
|
||||||
|
|
||||||
|
# Convert to our spot format
|
||||||
|
spot = Spot(source=self.name,
|
||||||
|
source_id=source_spot.guid.content,
|
||||||
|
dx_call=dx_call,
|
||||||
|
de_call=spotter,
|
||||||
|
freq=freq_hz,
|
||||||
|
mode=mode,
|
||||||
|
comment=comment,
|
||||||
|
sig="WOTA",
|
||||||
|
sig_refs=[ref] if ref else [],
|
||||||
|
sig_refs_names=[ref_name] if ref_name else [],
|
||||||
|
sig_refs_urls="https://www.wota.org.uk/MM_" + ref if ref else [],
|
||||||
|
icon=get_icon_for_sig("WOTA"),
|
||||||
|
time=time.timestamp())
|
||||||
|
|
||||||
|
# WOTA name/lat/lon lookup
|
||||||
|
wota_data = self.LIST_CACHE.get(self.LIST_URL, headers=HTTP_HEADERS).json()
|
||||||
|
for feature in wota_data["features"]:
|
||||||
|
if feature["properties"]["wotaId"] == spot.sig_refs[0]:
|
||||||
|
spot.sig_refs_names = [feature["properties"]["title"]]
|
||||||
|
spot.dx_latitude = feature["geometry"]["coordinates"][1]
|
||||||
|
spot.dx_longitude = feature["geometry"]["coordinates"][0]
|
||||||
|
spot.dx_grid = feature["properties"]["qthLocator"]
|
||||||
|
break
|
||||||
|
|
||||||
|
new_spots.append(spot)
|
||||||
|
return new_spots
|
||||||
59
spotproviders/zlota.py
Normal file
59
spotproviders/zlota.py
Normal file
@@ -0,0 +1,59 @@
|
|||||||
|
import csv
|
||||||
|
import logging
|
||||||
|
import re
|
||||||
|
from datetime import datetime, timedelta
|
||||||
|
|
||||||
|
import pytz
|
||||||
|
from requests_cache import CachedSession
|
||||||
|
|
||||||
|
from core.constants import HTTP_HEADERS
|
||||||
|
from core.sig_utils import get_icon_for_sig
|
||||||
|
from data.spot import Spot
|
||||||
|
from spotproviders.http_spot_provider import HTTPSpotProvider
|
||||||
|
|
||||||
|
|
||||||
|
# Spot provider for ZLOTA
|
||||||
|
class ZLOTA(HTTPSpotProvider):
|
||||||
|
POLL_INTERVAL_SEC = 120
|
||||||
|
SPOTS_URL = "https://ontheair.nz/api/spots?zlota_only=true"
|
||||||
|
LIST_URL = "https://ontheair.nz/assets/assets.json"
|
||||||
|
LIST_CACHE_TIME_DAYS = 30
|
||||||
|
LIST_CACHE = CachedSession("cache/zlota_data_cache", expire_after=timedelta(days=LIST_CACHE_TIME_DAYS))
|
||||||
|
|
||||||
|
def __init__(self, provider_config):
|
||||||
|
super().__init__(provider_config, self.SPOTS_URL, self.POLL_INTERVAL_SEC)
|
||||||
|
|
||||||
|
def http_response_to_spots(self, http_response):
|
||||||
|
new_spots = []
|
||||||
|
# Iterate through source data
|
||||||
|
for source_spot in http_response.json():
|
||||||
|
# Frequency is often inconsistent as to whether it's in Hz or kHz. Make a guess.
|
||||||
|
freq_hz = float(source_spot["frequency"])
|
||||||
|
if freq_hz < 1000000:
|
||||||
|
freq_hz = freq_hz * 1000
|
||||||
|
|
||||||
|
# Convert to our spot format
|
||||||
|
spot = Spot(source=self.name,
|
||||||
|
source_id=source_spot["id"],
|
||||||
|
dx_call=source_spot["activator"].upper(),
|
||||||
|
de_call=source_spot["spotter"].upper(),
|
||||||
|
freq=freq_hz,
|
||||||
|
mode=source_spot["mode"].upper().strip(),
|
||||||
|
comment=source_spot["comments"],
|
||||||
|
sig="ZLOTA",
|
||||||
|
sig_refs=[source_spot["reference"]],
|
||||||
|
sig_refs_names=[source_spot["name"]],
|
||||||
|
icon=get_icon_for_sig("ZLOTA"),
|
||||||
|
time=datetime.fromisoformat(source_spot["referenced_time"]).astimezone(pytz.UTC).timestamp())
|
||||||
|
|
||||||
|
# ZLOTA name/lat/lon lookup
|
||||||
|
zlota_data = self.LIST_CACHE.get(self.LIST_URL, headers=HTTP_HEADERS).json()
|
||||||
|
for asset in zlota_data:
|
||||||
|
if asset["code"] == spot.sig_refs[0]:
|
||||||
|
spot.sig_refs_names = [asset["name"]]
|
||||||
|
spot.dx_latitude = asset["y"]
|
||||||
|
spot.dx_longitude = asset["x"]
|
||||||
|
break
|
||||||
|
|
||||||
|
new_spots.append(spot)
|
||||||
|
return new_spots
|
||||||
@@ -24,6 +24,8 @@
|
|||||||
<h4 class="mt-4">Why does this website ask me if I want to install it?</h4>
|
<h4 class="mt-4">Why does this website ask me if I want to install it?</h4>
|
||||||
<p>Spothole is a Progressive Web App, which means you can install it on an Android or iOS device by opening the site in Chrome or Safari respectively, and clicking "Install" on the pop-up panel. It'll only prompt you once, so if you dismiss the prompt and change your mind, you'll find an Install / Add to Home Screen option on your browser's menu.</p>
|
<p>Spothole is a Progressive Web App, which means you can install it on an Android or iOS device by opening the site in Chrome or Safari respectively, and clicking "Install" on the pop-up panel. It'll only prompt you once, so if you dismiss the prompt and change your mind, you'll find an Install / Add to Home Screen option on your browser's menu.</p>
|
||||||
<p>Installing Spothole on your phone is completely optional, the website works exactly the same way as the "app" does.</p>
|
<p>Installing Spothole on your phone is completely optional, the website works exactly the same way as the "app" does.</p>
|
||||||
|
<h4 class="mt-4">Why hasn't my spot/alert shown up yet?</h4>
|
||||||
|
<p>To avoid putting too much load on the various servers that Spothole connects to, the Spothole server only polls them once every two minutes for spots, and once every hour for alerts. (Some sources, such as DX clusters, RBN, APRS-IS and WWBOTA use a non-polling mechanism, and their updates will therefore arrive more quickly.) Then if you are using the web interface, that has its own rate at which it reloads the data from Spothole, which is once a minute for spots or 30 minutes for alerts. So you could be waiting around three minutes to see a newly added spot, or 90 minutes to see a newly added alert.</p>
|
||||||
<h4 class="mt-4">What licence does Spothole use?</h4>
|
<h4 class="mt-4">What licence does Spothole use?</h4>
|
||||||
<p>Spothole's source code is licenced under the Public Domain. You can write a Spothole client, run your own server, modify it however you like, you can claim you wrote it and charge people £1000 for a copy, I don't really mind. (Please don't do the last one. But if you're using my code for something cool, it would be nice to hear from you!)</p>
|
<p>Spothole's source code is licenced under the Public Domain. You can write a Spothole client, run your own server, modify it however you like, you can claim you wrote it and charge people £1000 for a copy, I don't really mind. (Please don't do the last one. But if you're using my code for something cool, it would be nice to hear from you!)</p>
|
||||||
<h2 id="privacy" class="mt-4">Privacy</h2>
|
<h2 id="privacy" class="mt-4">Privacy</h2>
|
||||||
|
|||||||
@@ -1,11 +1,5 @@
|
|||||||
% rebase('webpage_base.tpl')
|
% rebase('webpage_base.tpl')
|
||||||
|
|
||||||
<div class="mt-3">
|
|
||||||
<div class="alert alert-warning" role="alert">
|
|
||||||
<i class="fa-solid fa-triangle-exclamation"></i> This page is a work in progress. It will be refined as Spothole heads towards v1.0.
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="mt-3">
|
<div class="mt-3">
|
||||||
<div class="row">
|
<div class="row">
|
||||||
<div class="col-auto me-auto pt-3">
|
<div class="col-auto me-auto pt-3">
|
||||||
|
|||||||
@@ -31,6 +31,7 @@
|
|||||||
<link href="/fa/css/solid.min.css" rel="stylesheet" />
|
<link href="/fa/css/solid.min.css" rel="stylesheet" />
|
||||||
|
|
||||||
<link rel="icon" type="image/png" href="/img/icon-512.png">
|
<link rel="icon" type="image/png" href="/img/icon-512.png">
|
||||||
|
<link rel="apple-touch-icon" href="img/icon-512-pwa.png">
|
||||||
<link rel="alternate icon" type="image/png" href="/img/icon-192.png">
|
<link rel="alternate icon" type="image/png" href="/img/icon-192.png">
|
||||||
<link rel="alternate icon" type="image/png" href="/img/icon-32.png">
|
<link rel="alternate icon" type="image/png" href="/img/icon-32.png">
|
||||||
<link rel="alternate icon" type="image/png" href="/img/icon-16.png">
|
<link rel="alternate icon" type="image/png" href="/img/icon-16.png">
|
||||||
|
|||||||
@@ -1016,4 +1016,4 @@ components:
|
|||||||
ref_regex:
|
ref_regex:
|
||||||
type: string
|
type: string
|
||||||
description: Regex that matches this SIG's reference IDs. Generally for Spothole's own internal use, clients probably won't need this.
|
description: Regex that matches this SIG's reference IDs. Generally for Spothole's own internal use, clients probably won't need this.
|
||||||
example: "[A-Z]{2}\-\d+"
|
example: "[A-Z]{2}\\-\\d+"
|
||||||
@@ -153,104 +153,95 @@ div#map {
|
|||||||
/* BANDS PANEL */
|
/* BANDS PANEL */
|
||||||
|
|
||||||
div#bands-container {
|
div#bands-container {
|
||||||
|
min-height: 64em;
|
||||||
margin: 0;
|
margin: 0;
|
||||||
padding: 0;
|
padding: 0;
|
||||||
overflow-x: auto;
|
overflow-x: auto;
|
||||||
overflow-y: auto;
|
|
||||||
white-space: nowrap;
|
white-space: nowrap;
|
||||||
display: flex;
|
|
||||||
overscroll-behavior-x: none;
|
overscroll-behavior-x: none;
|
||||||
}
|
}
|
||||||
|
|
||||||
/* Bands panel inner layout */
|
#bands-table {
|
||||||
div.bandCol {
|
min-width: 100%;
|
||||||
height: 100%;
|
|
||||||
min-width: 8em;
|
|
||||||
display: flex;
|
|
||||||
flex-flow: column;
|
|
||||||
overflow-y: clip;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
div.bandColHeader {
|
#bands-table th {
|
||||||
flex: 0 1 auto;
|
width: 20%;
|
||||||
}
|
max-height: 40px;
|
||||||
|
min-width: 12em;
|
||||||
div.bandColMiddle {
|
padding: 0.5em;
|
||||||
flex: 1 1 auto;
|
|
||||||
}
|
|
||||||
|
|
||||||
div.bandColMiddle ul {
|
|
||||||
display: table;
|
|
||||||
table-layout: fixed;
|
|
||||||
width: 100%;
|
|
||||||
min-height: 100%;
|
|
||||||
margin: 0;
|
|
||||||
padding: 0;
|
|
||||||
-moz-box-sizing: border-box;
|
|
||||||
box-sizing: border-box;
|
|
||||||
}
|
|
||||||
|
|
||||||
div.bandColMiddle ul li {
|
|
||||||
display: table-row;
|
|
||||||
line-height: 0.5em;
|
|
||||||
}
|
|
||||||
|
|
||||||
/*noinspection CssUnusedSymbol*/
|
|
||||||
div.bandColMiddle ul li.withSpots {
|
|
||||||
line-height: 1em;
|
|
||||||
}
|
|
||||||
|
|
||||||
div.bandColMiddle ul li span {
|
|
||||||
display: table-cell;
|
|
||||||
vertical-align: middle;
|
|
||||||
}
|
|
||||||
|
|
||||||
div.bandColMiddle ul {
|
|
||||||
display: table;
|
|
||||||
table-layout: fixed;
|
|
||||||
width: 100%;
|
|
||||||
height: 100%;
|
|
||||||
margin: 0;
|
|
||||||
padding: 0;
|
|
||||||
-moz-box-sizing: border-box;
|
|
||||||
box-sizing: border-box;
|
|
||||||
border-left: 2px dotted;
|
|
||||||
}
|
|
||||||
|
|
||||||
div.bandColHeader {
|
|
||||||
text-align: center;
|
text-align: center;
|
||||||
font-weight: bold;
|
font-weight: bold;
|
||||||
padding: 0.5em;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
div.bandColMiddle {
|
#bands-table td {
|
||||||
margin-left: 3px;
|
width: 20%;
|
||||||
border-left: 2px dotted var(--text);
|
min-width: 12em;
|
||||||
|
height: 62em;
|
||||||
}
|
}
|
||||||
|
|
||||||
div.bandColSpot {
|
div.band-container {
|
||||||
display: block;
|
height: 62em;
|
||||||
|
width: 20%;
|
||||||
|
min-width: 12em;
|
||||||
|
position: relative;
|
||||||
|
}
|
||||||
|
|
||||||
|
div.band-markers {
|
||||||
|
width: 100%;
|
||||||
|
height: 100%;
|
||||||
|
position: absolute;
|
||||||
|
top: 0;
|
||||||
|
left: 0;
|
||||||
|
z-index: 13;
|
||||||
|
border-left: 2px dotted black;
|
||||||
|
}
|
||||||
|
|
||||||
|
div.band-spots {
|
||||||
|
width: 100%;
|
||||||
|
height: 100%;
|
||||||
|
position: absolute;
|
||||||
|
top: 0;
|
||||||
|
left: 0;
|
||||||
|
z-index: 15;
|
||||||
|
}
|
||||||
|
|
||||||
|
canvas.band-lines-canvas {
|
||||||
|
width: 5em;
|
||||||
|
height: 100%;
|
||||||
|
position: absolute;
|
||||||
|
top: 0;
|
||||||
|
left: 0;
|
||||||
|
z-index: 11;
|
||||||
|
}
|
||||||
|
|
||||||
|
div.band-spot {
|
||||||
|
position: absolute;
|
||||||
|
left: 5em;
|
||||||
|
padding: 0 0.25em;
|
||||||
|
background-color: white;
|
||||||
border-radius: 3px;
|
border-radius: 3px;
|
||||||
padding: 3px;
|
cursor: default;
|
||||||
background: lightyellow;
|
|
||||||
margin-right: 2em;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
span.bandColSpot {
|
div.band-spot:hover {
|
||||||
vertical-align: bottom;
|
z-index: 999;
|
||||||
display: inline !important;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/* Don't wrap frequencies */
|
div.band-spot span.band-spot-call {
|
||||||
span.bandColSpotFreq {
|
display: inline;
|
||||||
white-space: nowrap;
|
|
||||||
display: inline !important;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
span.bandColSpotMode {
|
div.band-spot:hover span.band-spot-call {
|
||||||
padding-left: 0.5em;
|
display: none;
|
||||||
font-size: 0.8em;
|
}
|
||||||
line-height: 0.4em;
|
|
||||||
|
div.band-spot span.band-spot-info {
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
div.band-spot:hover span.band-spot-info {
|
||||||
|
display: inline;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -124,16 +124,12 @@ function addAlertRowsToTable(tbody, alerts) {
|
|||||||
var showRef = $("#tableShowRef")[0].checked;
|
var showRef = $("#tableShowRef")[0].checked;
|
||||||
|
|
||||||
// Get times for the alert, and convert to local time if necessary.
|
// Get times for the alert, and convert to local time if necessary.
|
||||||
var start_time_unix = moment.unix(a["start_time"]);
|
var start_time_utc = moment.unix(a["start_time"]).utc();
|
||||||
var start_time = start_time_unix.utc();
|
var start_time_local = start_time_utc.clone().local();
|
||||||
if (useLocalTime) {
|
start_time = useLocalTime ? start_time_local : start_time_utc;
|
||||||
start_time = start_time.local();
|
var end_time_utc = moment.unix(a["end_time"]).utc();
|
||||||
}
|
var end_time_local = end_time_utc.clone().local();
|
||||||
var end_time_unix = moment.unix(a["end_time"]);
|
end_time = useLocalTime ? end_time_local : end_time_utc;
|
||||||
var end_time = end_time_unix.utc();
|
|
||||||
if (useLocalTime) {
|
|
||||||
end_time = end_time.local();
|
|
||||||
}
|
|
||||||
|
|
||||||
// Format the times for display. Start time is displayed as e.g. 7 Oct 12:34 unless the time is in a
|
// Format the times for display. Start time is displayed as e.g. 7 Oct 12:34 unless the time is in a
|
||||||
// different year to the current year, in which case the year is inserted between month and hour.
|
// different year to the current year, in which case the year is inserted between month and hour.
|
||||||
@@ -143,8 +139,8 @@ function addAlertRowsToTable(tbody, alerts) {
|
|||||||
// Overriding all of that, if the start time is 00:00 and the end time is 23:59 when considered in UTC, the
|
// Overriding all of that, if the start time is 00:00 and the end time is 23:59 when considered in UTC, the
|
||||||
// hours and minutes are stripped out from the display, as we assume the server is just giving us full days.
|
// hours and minutes are stripped out from the display, as we assume the server is just giving us full days.
|
||||||
// Finally, if there is no end date set, "---" is displayed.
|
// Finally, if there is no end date set, "---" is displayed.
|
||||||
var whole_days = start_time_unix.utc().format("HH:mm") == "00:00" &&
|
var whole_days = start_time_utc.format("HH:mm") == "00:00" &&
|
||||||
(end_time_unix != null || end_time_unix > 0 || end_time_unix.utc().format("HH:mm") == "23:59");
|
(end_time_utc != null || end_time_utc > 0 || end_time_utc.format("HH:mm") == "23:59");
|
||||||
var hours_minutes_format = whole_days ? "" : " HH:mm";
|
var hours_minutes_format = whole_days ? "" : " HH:mm";
|
||||||
var start_time_formatted = start_time.format("D MMM" + hours_minutes_format);
|
var start_time_formatted = start_time.format("D MMM" + hours_minutes_format);
|
||||||
if (start_time.format("YYYY") != moment().format("YYYY")) {
|
if (start_time.format("YYYY") != moment().format("YYYY")) {
|
||||||
@@ -153,11 +149,13 @@ function addAlertRowsToTable(tbody, alerts) {
|
|||||||
start_time_formatted = start_time.format("[Today]" + hours_minutes_format);
|
start_time_formatted = start_time.format("[Today]" + hours_minutes_format);
|
||||||
}
|
}
|
||||||
var end_time_formatted = "---";
|
var end_time_formatted = "---";
|
||||||
if (end_time_unix != null && end_time_unix > 0 && end_time != null) {
|
if (end_time_utc != null && end_time_utc > 0 && end_time != null) {
|
||||||
var end_time_formatted = whole_days ? start_time_formatted : end_time.format("HH:mm");
|
var end_time_formatted = whole_days ? start_time_formatted : end_time.format("HH:mm");
|
||||||
if (end_time.format("D MMM") != start_time.format("D MMM")) {
|
if (end_time.format("D MMM") != start_time.format("D MMM")) {
|
||||||
if (end_time.format("YYYY") != moment().format("YYYY")) {
|
if (end_time.format("YYYY") != moment().format("YYYY")) {
|
||||||
end_time_formatted = end_time.format("D MMM YYYY" + hours_minutes_format);
|
end_time_formatted = end_time.format("D MMM YYYY" + hours_minutes_format);
|
||||||
|
} else if (useLocalTime && end_time.format("D MMM YYYY") == moment().format("D MMM YYYY")) {
|
||||||
|
end_time_formatted = end_time.format("[Today]" + hours_minutes_format);
|
||||||
} else {
|
} else {
|
||||||
end_time_formatted = end_time.format("D MMM" + hours_minutes_format);
|
end_time_formatted = end_time.format("D MMM" + hours_minutes_format);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,3 +1,12 @@
|
|||||||
|
// A couple of constants that must match what's in CSS. We need to know them before the content actually renders, so we
|
||||||
|
// can't just ask the elements themselves for their dimensions.
|
||||||
|
BAND_COLUMN_HEIGHT_EM = 62;
|
||||||
|
BAND_COLUMN_CANVAS_WIDTH_EM = 4;
|
||||||
|
BAND_COLUMN_FONT_SIZE = 16;
|
||||||
|
BAND_COLUMN_HEIGHT_PX = BAND_COLUMN_HEIGHT_EM * BAND_COLUMN_FONT_SIZE;
|
||||||
|
BAND_COLUMN_CANVAS_WIDTH_PX = BAND_COLUMN_CANVAS_WIDTH_EM * BAND_COLUMN_FONT_SIZE;
|
||||||
|
BAND_COLUMN_SPOT_DIV_HEIGHT_PX = BAND_COLUMN_FONT_SIZE * 1.6;
|
||||||
|
|
||||||
// Load spots and populate the bands display.
|
// Load spots and populate the bands display.
|
||||||
function loadSpots() {
|
function loadSpots() {
|
||||||
$.getJSON('/api/v1/spots' + buildQueryString(), function(jsonData) {
|
$.getJSON('/api/v1/spots' + buildQueryString(), function(jsonData) {
|
||||||
@@ -28,9 +37,9 @@ function buildQueryString() {
|
|||||||
// Update the bands display
|
// Update the bands display
|
||||||
function updateBands() {
|
function updateBands() {
|
||||||
// Stop here if nothing to display
|
// Stop here if nothing to display
|
||||||
var bandsPanel = $("#bands-container");
|
var bandsContainer = $("#bands-container");
|
||||||
if (spots.length === 0) {
|
if (spots.length === 0) {
|
||||||
bandsPanel.html("<div class='alert alert-danger' role='alert'>No spots match your filters.</div>");
|
bandsContainer.html("<div class='alert alert-danger' role='alert'>No spots match your filters.</div>");
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -51,73 +60,137 @@ function updateBands() {
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
// Build up HTML content for each band
|
// Track if any columns end up taller than expected, so we can resize the container and avoid vertical scroll.
|
||||||
let html = "";
|
var maxHeightBand = 0;
|
||||||
const columnWidthPercent = Math.max(30, 100 / bandToSpots.size);
|
|
||||||
let columnIndex = 0;
|
// Build up table content for each band
|
||||||
|
var table = $('<table id="bands-table">').append('<thead><tr></tr></thead><tbody><tr></tr></tbody>');
|
||||||
bandToSpots.forEach(function (spotList, bandName) {
|
bandToSpots.forEach(function (spotList, bandName) {
|
||||||
// Get the colours for the band from the first spot, and prepare the header
|
// Get the colours for the band from the first spot, and prepare the header
|
||||||
html += "<div class='bandCol' style='width:" + columnWidthPercent + "%'>";
|
table.find('thead tr').append(`<th style='background-color:${spotList[0].band_color}; color:${spotList[0].band_contrast_color}'>${spotList[0].band}</th>`);
|
||||||
html += "<div class='bandColHeader' style='background-color:" + spotList[0].band_color + "; color:" + spotList[0].band_contrast_color + "'>" + spotList[0].band + "</div>";
|
|
||||||
html += "<div class='bandColMiddle'>";
|
|
||||||
|
|
||||||
// Get the band data to fetch start and end frequencies
|
// Get the band data to fetch start and end frequencies
|
||||||
let band = options["bands"].filter(function (b) {
|
let band = options["bands"].filter(function (b) {
|
||||||
return b.name === bandName;
|
return b.name === bandName;
|
||||||
})[0];
|
})[0];
|
||||||
// Start printing the band
|
|
||||||
|
// Print the frequency band markers. This is 41 steps to divide the band evenly into 40 markers. One in every
|
||||||
|
// four will show the actual frequency, the others will just be dashes.
|
||||||
|
bandMarkersDiv = $('<div class="band-markers">');
|
||||||
const freqStep = (band.end_freq - band.start_freq) / 40.0;
|
const freqStep = (band.end_freq - band.start_freq) / 40.0;
|
||||||
html += "<ul>";
|
|
||||||
html += "<li><span>-</span></li>";
|
|
||||||
|
|
||||||
// Do 40 steps down the band
|
|
||||||
for (let i = 0; i <= 40; i++) {
|
for (let i = 0; i <= 40; i++) {
|
||||||
|
|
||||||
// Work out if there are any spots in this step
|
|
||||||
const freqStepStart = band.start_freq + i * freqStep;
|
|
||||||
const freqStepEnd = freqStepStart + freqStep;
|
|
||||||
const spotsInStep = spotList.filter(function (s) {
|
|
||||||
// Normally we do >= start and < end, but in the special case where this is the last step and there is a spot
|
|
||||||
// right at the end of the band, we include this too
|
|
||||||
return s.freq >= freqStepStart && (s.freq < freqStepEnd || (s.freq === freqStepEnd && freqStepEnd === band.end_freq));
|
|
||||||
});
|
|
||||||
|
|
||||||
if (spotsInStep.length > 0) {
|
|
||||||
// If this step has spots in it, print them
|
|
||||||
html += "<li class='withSpots'><span>";
|
|
||||||
spotsInStep.sort((a, b) => (a.freq > b.freq) ? 1 : ((b.freq > a.freq) ? -1 : 0));
|
|
||||||
spotsInStep.forEach(function (s) {
|
|
||||||
html += "<div class='bandColSpot'><span class='bandColSpot'>" + s.dx_call + "<br/><span class='bandColSpotFreq'>" + (s.freq/1000000) + "</span>";
|
|
||||||
if (s.mode != null && s.mode.length > 0 && s.mode !== "Unknown") {
|
|
||||||
html += "<span class='bandColSpotMode'>" + s.mode + "</span>";
|
|
||||||
}
|
|
||||||
html += "</span></div>";
|
|
||||||
});
|
|
||||||
html += "</li></span>";
|
|
||||||
|
|
||||||
} else {
|
|
||||||
// Step had no spots in it, so just print a marker. This is a frequency on multiples of 4, or a dash otherwise.
|
|
||||||
if (i % 4 === 0) {
|
if (i % 4 === 0) {
|
||||||
html += "<li><span>—" + ((band.start_freq + i * freqStep)/1000000).toFixed(3) + "</span></li>";
|
bandMarkersDiv.append("—" + ((band.start_freq + i * freqStep)/1000000).toFixed(3) + "<br/>");
|
||||||
} else if (i % 4 === 2) {
|
} else if (i % 4 === 2) {
|
||||||
html += "<li><span>–</span></li>";
|
bandMarkersDiv.append("–<br/>");
|
||||||
} else {
|
} else {
|
||||||
html += "<li><span>-</span></li>";
|
bandMarkersDiv.append("-<br/>");
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
|
||||||
html += "<li><span>-</span></li>";
|
|
||||||
html += "</ul>";
|
|
||||||
|
|
||||||
html += "</div></div>";
|
// Prepare the spots list
|
||||||
columnIndex++;
|
var bandSpotsDiv = $("<div class='band-spots'>");
|
||||||
|
var lastSpotPxDownBand = -999;
|
||||||
|
// Sort by frequency so have a consistent order in which to plan where they will appear on the band div.
|
||||||
|
spotList.sort(function(a, b) { return a.freq - b.freq; });
|
||||||
|
// First calculate how we should be displaying the spots. There are three "modes" to try to place them in a
|
||||||
|
// visually appealing way:
|
||||||
|
// 1) Spaced normally, not going over the end of the band, so we populate them forwards.
|
||||||
|
// 2) Would go over the end, but the spots don't fill the band, so we populate them backwards.
|
||||||
|
// 3) Spots totally fill the band (or more), so we space them evenly starting at the top.
|
||||||
|
// In each case, we don't add anything to the DOM yet, we just calculate "pxDownBandLabel" (how far the *top* of
|
||||||
|
// the label is from the top of the div) and add that as a property to the spot for later use.
|
||||||
|
if (spotList.length >= BAND_COLUMN_HEIGHT_PX / BAND_COLUMN_SPOT_DIV_HEIGHT_PX) {
|
||||||
|
// Mode 3.
|
||||||
|
// Just lay out all spots simply, starting at 0px offset and working down with each one touching.
|
||||||
|
lastSpotPxDownBand = 0 - BAND_COLUMN_SPOT_DIV_HEIGHT_PX;
|
||||||
|
spotList.forEach(s => {
|
||||||
|
lastSpotPxDownBand = lastSpotPxDownBand + BAND_COLUMN_SPOT_DIV_HEIGHT_PX;
|
||||||
|
s["pxDownBandLabel"] = lastSpotPxDownBand;
|
||||||
});
|
});
|
||||||
|
} else {
|
||||||
|
// Mode 1 or 2. Run through adding things to the list forwards as a test.
|
||||||
|
spotList.forEach(s => {
|
||||||
|
// Work out how far down the div to draw it
|
||||||
|
var percentDownBand = (s.freq - band.start_freq) / (band.end_freq - band.start_freq) * 0.97; // not 100% due to fudge, the first and last dashes are not exactly at the top and bottom of the div as some space is needed for text
|
||||||
|
var pxDownBand = percentDownBand * BAND_COLUMN_HEIGHT_PX;
|
||||||
|
if (pxDownBand < lastSpotPxDownBand + BAND_COLUMN_SPOT_DIV_HEIGHT_PX) {
|
||||||
|
pxDownBand = lastSpotPxDownBand + BAND_COLUMN_SPOT_DIV_HEIGHT_PX; // Prevent overlap
|
||||||
|
}
|
||||||
|
s["pxDownBandLabel"] = pxDownBand;
|
||||||
|
lastSpotPxDownBand = pxDownBand;
|
||||||
|
});
|
||||||
|
|
||||||
|
// Work out if we overflowed the end.
|
||||||
|
if (lastSpotPxDownBand <= BAND_COLUMN_HEIGHT_PX) {
|
||||||
|
// Mode 1. Current positions are fine and there's nothing to do.
|
||||||
|
} else {
|
||||||
|
// Mode 2. Repeat the process but backwards, starting at the end and working upwards.
|
||||||
|
lastSpotPxDownBand = 999999;
|
||||||
|
spotList.reverse().forEach(s => {
|
||||||
|
// Work out how far down the div to draw it
|
||||||
|
var percentDownBand = (s.freq - band.start_freq) / (band.end_freq - band.start_freq) * 0.97; // not 100% due to fudge, the first and last dashes are not exactly at the top and bottom of the div as some space is needed for text
|
||||||
|
var pxDownBand = percentDownBand * BAND_COLUMN_HEIGHT_PX;
|
||||||
|
if (pxDownBand > lastSpotPxDownBand - BAND_COLUMN_SPOT_DIV_HEIGHT_PX) {
|
||||||
|
pxDownBand = lastSpotPxDownBand - BAND_COLUMN_SPOT_DIV_HEIGHT_PX; // Prevent overlap
|
||||||
|
}
|
||||||
|
s["pxDownBandLabel"] = pxDownBand;
|
||||||
|
lastSpotPxDownBand = pxDownBand;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Now each spot is tagged with how far down the div it should go, add them to the DOM.
|
||||||
|
spotList.forEach(s => {
|
||||||
|
bandSpotsDiv.append(`<div class="band-spot" style="top: ${s['pxDownBandLabel']}px; border-top: 1px solid ${s.band_color}; border-left: 5px solid ${s.band_color}; border-bottom: 1px solid ${s.band_color}; border-right: 1px solid ${s.band_color};"><span class="band-spot-call">${s.dx_call}</span><span class="band-spot-info">${s.dx_call} ${(s.freq/1000000).toFixed(3)} ${s.mode}</span></div>`);
|
||||||
|
});
|
||||||
|
|
||||||
|
// Work out how tall the canvas should be. Normally this is matching the normal band column height, but if some
|
||||||
|
// spots have gone off the end of the band markers and stretched their div, we need to resize the canvas to
|
||||||
|
// match, otherwise we have nowhere to draw their connecting lines.
|
||||||
|
var canvasHeight = Math.max(BAND_COLUMN_HEIGHT_PX, lastSpotPxDownBand + BAND_COLUMN_SPOT_DIV_HEIGHT_PX);
|
||||||
|
maxHeightBand = Math.max(maxHeightBand, canvasHeight);
|
||||||
|
|
||||||
|
// Draw horizontal or diagonal lines to join up the "real" frequency with where the spot div ended up
|
||||||
|
var bandLinesCanvas = $(`<canvas class='band-lines-canvas' width='${BAND_COLUMN_CANVAS_WIDTH_PX}px' height='${canvasHeight}px' style='height:${canvasHeight}px !important;'>`);
|
||||||
|
spotList.forEach(s => {
|
||||||
|
// Work out how far down the div to draw it
|
||||||
|
var percentDownBand = (s.freq - band.start_freq) / (band.end_freq - band.start_freq) * 0.97; // not 100% due to fudge, the first and last dashes are not exactly at the top and bottom of the div as some space is needed for text
|
||||||
|
var pxDownBandFreq = (percentDownBand + 0.015) * BAND_COLUMN_HEIGHT_PX; // same fudge but add half to put the left end of the line in the right place
|
||||||
|
var pxDownBandLabel = s["pxDownBandLabel"] + (BAND_COLUMN_SPOT_DIV_HEIGHT_PX / 1.75); // line should be to the vertical text-centre spot, not to the top corner
|
||||||
|
|
||||||
|
// Draw the line on the canvas
|
||||||
|
var ctx = bandLinesCanvas[0].getContext('2d');
|
||||||
|
ctx.beginPath();
|
||||||
|
ctx.lineWidth = 2;
|
||||||
|
ctx.lineCap = "round";
|
||||||
|
ctx.strokeStyle = s.band_color;
|
||||||
|
ctx.moveTo(0, pxDownBandFreq);
|
||||||
|
ctx.lineTo(BAND_COLUMN_CANVAS_WIDTH_PX, pxDownBandLabel);
|
||||||
|
ctx.stroke();
|
||||||
|
});
|
||||||
|
|
||||||
|
// Assemble the table cell
|
||||||
|
td = $("<td>");
|
||||||
|
container = $("<div class='band-container'>");
|
||||||
|
container.append(bandLinesCanvas);
|
||||||
|
container.append(bandMarkersDiv);
|
||||||
|
container.append(bandSpotsDiv);
|
||||||
|
td.append(container);
|
||||||
|
table.find('tbody tr').append(td);
|
||||||
|
});
|
||||||
|
|
||||||
// Update the DOM with the band HTML
|
// Update the DOM with the band HTML
|
||||||
bandsPanel.html(html);
|
bandsContainer.html(table);
|
||||||
|
|
||||||
|
// Increase the height of the bands container so we don't have any vertical scroll bars except the browser ones
|
||||||
|
bandsContainer.css("min-height", `${maxHeightBand + 42}px`);
|
||||||
|
|
||||||
// Desktop mouse wheel to scroll bands horizontally if used on the headers
|
// Desktop mouse wheel to scroll bands horizontally if used on the headers
|
||||||
// noinspection JSDeprecatedSymbols
|
table.find('thead tr').on("wheel", () => {
|
||||||
$(".bandColHeader").on("wheel", () => bandsPanel.scrollLeft(bandsPanel.scrollLeft() + event.deltaY / 10.0));
|
bandsContainer.scrollLeft(bandsContainer.scrollLeft() + event.deltaY / 10.0);
|
||||||
|
return false;
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
// Iterate through a temporary list of spots, merging duplicates in a way suitable for the band panel. If two or more
|
// Iterate through a temporary list of spots, merging duplicates in a way suitable for the band panel. If two or more
|
||||||
|
|||||||
@@ -88,7 +88,7 @@ function updateTable() {
|
|||||||
// Format a UTC or local time for display
|
// Format a UTC or local time for display
|
||||||
var time = moment.unix(s["time"]).utc();
|
var time = moment.unix(s["time"]).utc();
|
||||||
if (useLocalTime) {
|
if (useLocalTime) {
|
||||||
time = time.local();
|
time.local();
|
||||||
}
|
}
|
||||||
var time_formatted = time.format("HH:mm");
|
var time_formatted = time.format("HH:mm");
|
||||||
|
|
||||||
@@ -185,7 +185,7 @@ function updateTable() {
|
|||||||
$tr.append(`<td class='nowrap'>${time_formatted}</td>`);
|
$tr.append(`<td class='nowrap'>${time_formatted}</td>`);
|
||||||
}
|
}
|
||||||
if (showDX) {
|
if (showDX) {
|
||||||
$tr.append(`<td class='nowrap'><span class='flag-wrapper hideonmobile' title='${dx_country}'>${dx_flag}</span><a class='dx-link' href='https://qrz.com/db/${s["dx_call"]}' target='_new'>${s["dx_call"]}</a></td>`);
|
$tr.append(`<td class='nowrap'><span class='flag-wrapper hideonmobile' title='${dx_country}'>${dx_flag}</span><a class='dx-link' href='https://qrz.com/db/${s["dx_call"]}' target='_new' title='${s["dx_name"] != null ? s["dx_name"] : ""}'>${s["dx_call"]}</a></td>`);
|
||||||
}
|
}
|
||||||
if (showFreq) {
|
if (showFreq) {
|
||||||
$tr.append(`<td class='nowrap'><span class='band-bullet' title='${bandFullName}' style='color: ${s["band_color"]}'>■</span>${freq_string}</td>`);
|
$tr.append(`<td class='nowrap'><span class='band-bullet' title='${bandFullName}' style='color: ${s["band_color"]}'>■</span>${freq_string}</td>`);
|
||||||
|
|||||||
Reference in New Issue
Block a user