Author SHA1 Message Date
Ian Renton 74ce486098 UI tweaks 2026-05-09 17:10:15 +01:00
Ian Renton 1ef8b36cb1 Modify the front so that it allows QRZ.com and HamQTH credentials to be provided by the client (if none are provided, the lookups do not occur.) 2026-05-09 16:52:48 +01:00
Ian Renton f81ef4347f Modify the backend so that instead of using the server owner's QRZ & HamQTH credentials, it instead requires them to be provided by the client (if none are provided, the lookups do not occur.) 2026-05-09 15:43:22 +01:00
Ian Renton 0988a567b8 Add support for Tiles on the Air 2026-05-03 17:32:16 +01:00
Ian Renton 461ce94204 Cache-busting ?v= strings for CSS 2026-04-22 10:23:50 +01:00
Ian Renton 49949a0b2e Fix display of the last time cleanup ran 2026-04-11 08:17:30 +01:00
Ian Renton a3332aa023 Fix a parsing bug with NG3K 2026-04-11 08:14:52 +01:00
Ian Renton ac1ab4bd2d Ping on new spots option 2026-04-10 08:05:57 +01:00
Ian Renton 82944b9c38 Layout tweaks 2026-04-10 08:02:45 +01:00
Ian Renton 36dba30089 Ping on new spots option 2026-04-10 07:51:26 +01:00
Ian Renton 1ed175e099 Layout fix 2026-04-07 06:20:07 +01:00
Ian Renton 3870e560ec Bring localstorage stuff in from jsutils, it's only used here 2026-04-06 19:11:47 +01:00
Ian Renton 236ac1a584 Wider bands/sigs/sources columns on mobile 2026-04-06 18:22:45 +01:00
Ian Renton 9243f98604 Style tweak 2026-04-06 16:37:45 +01:00
Ian Renton 8f062320d3 Re-add Dark Mapnik theme (via dodgy CSS hacks) 2026-04-06 16:16:19 +01:00
Ian Renton 60126b0010 Add the ability to centre and zoom the map with URL params. #50 2026-04-05 10:42:01 +01:00
Ian Renton 06c16e2f1f Zoom to the extent of map markers on first load #50 2026-04-05 10:26:20 +01:00
Ian Renton b3353b168c Replace toggle buttons with checkboxes for better clarity of function 2026-04-05 10:03:42 +01:00
Ian Renton e170f9c6c2 Merge remote-tracking branch 'origin/main'
# Conflicts:
#	templates/about.html
#	templates/add_spot.html
#	templates/alerts.html
#	templates/bands.html
#	templates/base.html
#	templates/conditions.html
#	templates/map.html
#	templates/spots.html
#	templates/status.html
2026-04-05 09:28:44 +01:00
Ian Renton 497b84f5dc Bring Spothole mapping to parity with my other tools by adding choice of basemap, opacity and overlays #50 2026-04-05 09:27:23 +01:00
Ian Renton d51e5184a1 Radio blackout (R) scale 2026-04-04 10:45:42 +01:00
Ian Renton 429b278bca Improve K-index chart 2026-04-04 10:28:11 +01:00
Ian Renton 76b0ec24b7 Hide conditions page entries if data isn't available 2026-04-03 21:47:35 +01:00
Ian Renton 64afd4ed55 "Now" line on Kp forecast 2026-04-03 19:49:28 +01:00
Ian Renton d71908455a Kp forecast axis swap on mobile 2026-04-03 19:40:56 +01:00
Ian Renton c10b5e4947 Add fetching of NOAA 3-day forecast 2026-04-03 18:11:45 +01:00
Ian Renton 4a6d9da031 Add fetching of NOAA 3-day forecast 2026-04-03 17:40:00 +01:00
Ian Renton 9d04f8ea38 Add fetching of NOAA 3-day forecast 2026-04-03 17:22:59 +01:00
Ian Renton df9a82cad3 Add fetching of NOAA 3-day forecast 2026-04-03 17:10:36 +01:00
Ian Renton da7bb4223e Allow floating point received_since times 2026-04-03 15:35:06 +01:00
Ian Renton 8d2fcc69b0 More tweaks for string lat/lons 2026-04-03 15:32:48 +01:00
Ian Renton 9cfc3051a5 Support EH23 TOTA 2026-04-03 15:19:35 +01:00
62 changed files with 1940 additions and 553 deletions
+1 -1
View File
@@ -4,7 +4,7 @@
<content url="file://$MODULE_DIR$">
<excludeFolder url="file://$MODULE_DIR$/.venv" />
</content>
<orderEntry type="jdk" jdkName="Python 3.13 (spothole)" jdkType="Python SDK" />
<orderEntry type="jdk" jdkName="Python 3.13 virtualenv at ~/code/spothole/.venv" jdkType="Python SDK" />
<orderEntry type="sourceFolder" forTests="false" />
</component>
</module>
+18 -15
View File
@@ -10,7 +10,7 @@ The API is deliberately well-defined with an OpenAPI specification and auto-gene
Spothole itself is also open source, Public Domain licenced code that anyone can take and modify.
Supported data sources include DX Clusters, the Reverse Beacon Network (RBN), the APRS Internet Service (APRS-IS), POTA, SOTA, WWFF, GMA, WWBOTA, HEMA, Parks 'n' Peaks, ZLOTA, WOTA, BOTA, LLOTA, WWTOTA, the UK Packet Repeater Network, NG3K, and any site based on the xOTA software by nischu.
Supported data sources include DX Clusters, the Reverse Beacon Network (RBN), the APRS Internet Service (APRS-IS), POTA, SOTA, WWFF, GMA, WWBOTA, HEMA, Parks 'n' Peaks, ZLOTA, WOTA, BOTA, LLOTA, WWTOTA, Tiles on the Air, the UK Packet Repeater Network, NG3K, and any site based on the xOTA software by nischu.
![Screenshot](/images/screenshot2.png)
@@ -34,20 +34,23 @@ These are supplied with the URL to the page you want to embed, for example for a
The supported parameters are as follows. Generally these match the equivalent parameters in the real Spothole API, where a mapping exists.
| Name | Allowed Values | Default | Example | Description |
|----------------|-------------------------|---------|-------------------------|-----------------------------------------------------------------------------------------------------------------------------------------------------------------------|
| `embedded` | `true`, `false` | `false` | `?embedded=true` | Enables embedded mode. |
| `color-scheme` | `light`, `dark`, `auto` | `auto` | `?color-scheme=dark` | Forces light or dark mode in preference to the operating system default. |
| `time-zone` | `UTC`, `local` | `UTC` | `?time-zone=local` | Sets times to be in UTC or local time. |
| `limit` | 10, 25, 50, 100 | 50 | `?limit=50` | Sets the number of spots that will be displayed on the main spots page |
| `limit` | 25, 50, 100, 200, 500 | 100 | `?limit=100` | Sets the number of alerts that will be displayed on the alerts page |
| `max_age` | 300, 600, 1800, 3600 | 1800 | `?max_age=1800` | Sets the maximum age of spots displayed on the map and bands pages, in seconds. |
| `band` | Comma-separated list | (all) | `?band=20m,40m` | Sets the list of bands that will be shown on the spots, bands and map pages. Available options match the labels of the buttons in the standard web interface. |
| `sig` | Comma-separated list | (all) | `?sig=POTA,SOTA,NO_SIG` | Sets the list of SIGs that will be shown on the spots, bands and map pages. Available options match the labels of the buttons in the standard web interface. |
| `source` | Comma-separated list | (all) | `?source=Cluster` | Sets the list of sources that will be shown on any spot or alert pages. Available options match the labels of the buttons in the standard web interface. |
| `mode_type` | Comma-separated list | (all) | `?mode_type=PHONE,CW` | Sets the list of mode types that will be shown on the spots, bands and map pages. Available options match the labels of the buttons in the standard web interface. |
| `dx_continent` | Comma-separated list | (all) | `?dx_continent=NA,SA` | Sets the list of DX Continents that will be shown on any spot or alert pages. Available options match the labels of the buttons in the standard web interface. |
| `de_continent` | Comma-separated list | (all) | `?de_continent=EU` | Sets the list of DE Continents that will be shown on the spots, bands and map pages. Available options match the labels of the buttons in the standard web interface. |
| Name | Allowed Values | Default | Example | Description |
|-------------------|-------------------------|---------|--------------------------------|-----------------------------------------------------------------------------------------------------------------------------------------------------------------------|
| `embedded` | `true`, `false` | `false` | `?embedded=true` | Enables embedded mode. |
| `color-scheme` | `light`, `dark`, `auto` | `auto` | `?color-scheme=dark` | Forces light or dark mode in preference to the operating system default. |
| `time-zone` | `UTC`, `local` | `UTC` | `?time-zone=local` | Sets times to be in UTC or local time. |
| `limit` | 10, 25, 50, 100 | 50 | `?limit=50` | Sets the number of spots that will be displayed on the main spots page |
| `limit` | 25, 50, 100, 200, 500 | 100 | `?limit=100` | Sets the number of alerts that will be displayed on the alerts page |
| `max_age` | 300, 600, 1800, 3600 | 1800 | `?max_age=1800` | Sets the maximum age of spots displayed on the map and bands pages, in seconds. |
| `band` | Comma-separated list | (all) | `?band=20m,40m` | Sets the list of bands that will be shown on the spots, bands and map pages. Available options match the labels of the buttons in the standard web interface. |
| `sig` | Comma-separated list | (all) | `?sig=POTA,SOTA,NO_SIG` | Sets the list of SIGs that will be shown on the spots, bands and map pages. Available options match the labels of the buttons in the standard web interface. |
| `source` | Comma-separated list | (all) | `?source=Cluster` | Sets the list of sources that will be shown on any spot or alert pages. Available options match the labels of the buttons in the standard web interface. |
| `mode_type` | Comma-separated list | (all) | `?mode_type=PHONE,CW` | Sets the list of mode types that will be shown on the spots, bands and map pages. Available options match the labels of the buttons in the standard web interface. |
| `dx_continent` | Comma-separated list | (all) | `?dx_continent=NA,SA` | Sets the list of DX Continents that will be shown on any spot or alert pages. Available options match the labels of the buttons in the standard web interface. |
| `de_continent` | Comma-separated list | (all) | `?de_continent=EU` | Sets the list of DE Continents that will be shown on the spots, bands and map pages. Available options match the labels of the buttons in the standard web interface. |
| `map-center-lat` | Numeric (decimal) | (auto) | `?map-center-lat=51.5` | Sets the initial latitude of the map centre on the map page. If omitted, the map auto-fits to the loaded spots. |
| `map-center-lon` | Numeric (decimal) | (auto) | `?map-center-lon=-0.1` | Sets the initial longitude of the map centre on the map page. If omitted, the map auto-fits to the loaded spots. |
| `map-zoom` | Numeric (integer) | (auto) | `?map-zoom=6` | Sets the initial zoom level of the map on the map page. If omitted, the map auto-fits to the loaded spots. |
More will be added soon to allow customisation of filters and other display properties.
+3 -3
View File
@@ -68,9 +68,9 @@ class NG3K(HTTPAlertProvider):
dx_country = parts[1]
qsl_info = parts[3]
bands = extra_parts[1]
modes = extra_parts[2] if len(extra_parts) > 3 else ""
comment = extra_parts[-1]
bands = extra_parts[1] if len(extra_parts) > 1 else ""
modes = extra_parts[2] if len(extra_parts) > 2 else ""
comment = extra_parts[3] if len(extra_parts) > 3 else ""
# Convert to our alert format
alert = Alert(source=self.name,
+17 -10
View File
@@ -60,6 +60,10 @@ spot-providers:
class: "WWTOTA"
name: "WWTOTA"
enabled: true
-
class: "Tiles"
name: "Tiles"
enabled: true
-
class: "APRSIS"
name: "APRS-IS"
@@ -121,12 +125,20 @@ spot-providers:
class: "XOTA"
name: "39C3 TOTA"
enabled: false
url: "wss://dev.39c3.totawatch.de/api/spot/live"
url: "wss://39c3.totawatch.de/api/spot/live"
# Fixed SIG for all spots from a provider & location CSV are currently only a feature for the "XOTA" provider,
# the software found at https://github.com/nischu/xOTA/. This is because this is a generic backend for xOTA
# programmes and so different URLs provide different programmes.
sig: "TOTA"
locations-csv: "datafiles/39c3-tota.csv"
-
class: "XOTA"
name: "EH23 TOTA"
enabled: true
url: "wss://eh23.totawatch.de/api/spot/live"
sig: "TOTA"
locations-csv: "datafiles/eh23-tota.csv"
# Alert providers to use. Same setup as the spot providers list above.
alert-providers:
@@ -167,6 +179,10 @@ solar-condition-providers:
class: "HamQSL"
name: "HamQSL"
enabled: true
-
class: "NOAA3dayForecast"
name: "NOAA 3-day Forecast"
enabled: true
# Port to open the local web server on
web-server-port: 8080
@@ -176,15 +192,6 @@ web-server-port: 8080
max-spot-age-sec: 3600
max-alert-age-sec: 604800
# Login for QRZ.com to look up information. Optional. You will need an "XML Subscriber" (paid) package to retrieve all
# the data for a callsign via their system.
qrz-username: ""
qrz-password: ""
# Login for HamQTH to look up information. Optional.
hamqth-username: ""
hamqth-password: ""
# API key for Clublog to look up information. Optional. You sill need to request one via their helpdesk portal if you
# want to use callsign lookups from Clublog.
clublog-api-key: ""
+1
View File
@@ -30,6 +30,7 @@ SIGS = [
SIG(name="KRMNPA", description="Keith Roget Memorial National Parks Award"),
SIG(name="LLOTA", description="Lagos y Lagunas on the Air", ref_regex=r"[A-Z]{2}\-\d{4}"),
SIG(name="WWTOTA", description="Towers on the Air", ref_regex=r"[A-Z]{2}R\-\d{4}"),
SIG(name="Tiles", description="Tiles on the Air", ref_regex=r"[A-Za-z]{2}[0-9]{2}[A-Za-z]{2}"),
SIG(name="WAB", description="Worked All Britain", ref_regex=r"[A-Z]{1,2}[0-9]{2}"),
SIG(name="WAI", description="Worked All Ireland", ref_regex=r"[A-Z][0-9]{2}"),
SIG(name="TOTA", description="Toilets on the Air", ref_regex=r"T\-[0-9]{2}")
+153 -86
View File
@@ -5,6 +5,7 @@ import re
import urllib.parse
from datetime import timedelta
import requests
import xmltodict
from diskcache import Cache
from pyhamtools import LookupLib, Callinfo, callinfo
@@ -17,6 +18,38 @@ from core.cache_utils import SEMI_STATIC_URL_DATA_CACHE
from core.config import config
from core.constants import BANDS, UNKNOWN_BAND, CW_MODES, PHONE_MODES, DATA_MODES, ALL_MODES, \
HTTP_HEADERS, HAMQTH_PRG, MODE_ALIASES
from data.lookup_credentials import LookupCredentials
# QRZ XML field names differ from pyhamtools' normalised names; map them here.
_QRZ_FIELD_MAP = {
"lat": "latitude",
"lon": "longitude",
"grid": "locator",
"ituzone": "ituz",
"cqzone": "cqz",
}
_QRZ_INT_FIELDS = {"adif", "cqz", "ituz"}
_QRZ_FLOAT_FIELDS = {"latitude", "longitude"}
def _normalize_qrz_data(raw):
data = {}
for k, v in raw.items():
if v is None:
continue
mapped_key = _QRZ_FIELD_MAP.get(k, k)
if mapped_key in _QRZ_INT_FIELDS:
try:
v = int(v)
except (ValueError, TypeError):
pass
elif mapped_key in _QRZ_FLOAT_FIELDS:
try:
v = float(v)
except (ValueError, TypeError):
pass
data[mapped_key] = v
return data
class LookupHelper:
@@ -36,9 +69,10 @@ class LookupHelper:
self._clublog_cty_xml_cache = None
self._clublog_api_key = None
self._qrz_callsign_data_cache = None
self._lookup_lib_qrz = None
self._qrz_available = None
self._hamqth_available = None
self._qrz_base_url = "https://xmldata.qrz.com/xml/current/"
# QRZ session keys expire after an hour; cache the login response for 55 minutes.
self._qrz_session_cache = CachedSession("cache/qrz_session_cache",
expire_after=timedelta(minutes=55))
self._hamqth_callsign_data_cache = None
self._hamqth_base_url = "https://www.hamqth.com/xml.php"
# HamQTH session keys expire after an hour. Rather than working out how much time has passed manually, we cheat
@@ -67,13 +101,8 @@ class LookupHelper:
self._lookup_lib_basic = LookupLib(lookuptype="countryfile")
self._call_info_basic = Callinfo(self._lookup_lib_basic)
self._qrz_available = config["qrz-username"] != "" and config["qrz-password"] != ""
if self._qrz_available:
self._lookup_lib_qrz = LookupLib(lookuptype="qrz", username=config["qrz-username"],
pwd=config["qrz-password"])
self._qrz_callsign_data_cache = Cache('cache/qrz_callsign_lookup_cache')
self._hamqth_available = config["hamqth-username"] != "" and config["hamqth-password"] != ""
self._hamqth_callsign_data_cache = Cache('cache/hamqth_callsign_lookup_cache')
self._clublog_api_key = config["clublog-api-key"]
@@ -166,7 +195,7 @@ class LookupHelper:
logging.error("Exception when downloading Clublog cty.xml", e)
return False
def infer_country_from_callsign(self, call):
def infer_country_from_callsign(self, call, credentials=None):
"""Infer a country name from a callsign"""
try:
@@ -176,12 +205,12 @@ class LookupHelper:
country = None
# Couldn't get anything from basic call info database, try QRZ.com
if not country:
qrz_data = self._get_qrz_data_for_callsign(call)
qrz_data = self._get_qrz_data_for_callsign(call, credentials)
if qrz_data and "country" in qrz_data:
country = qrz_data["country"]
# Couldn't get anything from QRZ.com database, try HamQTH
if not country:
hamqth_data = self._get_hamqth_data_for_callsign(call)
hamqth_data = self._get_hamqth_data_for_callsign(call, credentials)
if hamqth_data and "country" in hamqth_data:
country = hamqth_data["country"]
# Couldn't get anything from HamQTH database, try Clublog data
@@ -200,7 +229,7 @@ class LookupHelper:
country = dxcc_data["name"]
return country
def infer_dxcc_id_from_callsign(self, call):
def infer_dxcc_id_from_callsign(self, call, credentials=None):
"""Infer a DXCC ID from a callsign"""
try:
@@ -210,12 +239,12 @@ class LookupHelper:
dxcc = None
# Couldn't get anything from basic call info database, try QRZ.com
if not dxcc:
qrz_data = self._get_qrz_data_for_callsign(call)
qrz_data = self._get_qrz_data_for_callsign(call, credentials)
if qrz_data and "adif" in qrz_data:
dxcc = qrz_data["adif"]
# Couldn't get anything from QRZ.com database, try HamQTH
if not dxcc:
hamqth_data = self._get_hamqth_data_for_callsign(call)
hamqth_data = self._get_hamqth_data_for_callsign(call, credentials)
if hamqth_data and "adif" in hamqth_data:
dxcc = hamqth_data["adif"]
# Couldn't get anything from HamQTH database, try Clublog data
@@ -234,7 +263,7 @@ class LookupHelper:
dxcc = dxcc_data["entityCode"]
return dxcc
def infer_continent_from_callsign(self, call):
def infer_continent_from_callsign(self, call, credentials=None):
"""Infer a continent shortcode from a callsign"""
try:
@@ -244,7 +273,7 @@ class LookupHelper:
continent = None
# Couldn't get anything from basic call info database, try HamQTH
if not continent:
hamqth_data = self._get_hamqth_data_for_callsign(call)
hamqth_data = self._get_hamqth_data_for_callsign(call, credentials)
if hamqth_data and "continent" in hamqth_data:
continent = hamqth_data["continent"]
# Couldn't get anything from HamQTH database, try Clublog data
@@ -264,7 +293,7 @@ class LookupHelper:
continent = dxcc_data["continent"][0]
return continent
def infer_cq_zone_from_callsign(self, call):
def infer_cq_zone_from_callsign(self, call, credentials=None):
"""Infer a CQ zone from a callsign"""
try:
@@ -274,12 +303,12 @@ class LookupHelper:
cqz = None
# Couldn't get anything from basic call info database, try QRZ.com
if not cqz:
qrz_data = self._get_qrz_data_for_callsign(call)
qrz_data = self._get_qrz_data_for_callsign(call, credentials)
if qrz_data and "cqz" in qrz_data:
cqz = qrz_data["cqz"]
# Couldn't get anything from QRZ.com database, try HamQTH
if not cqz:
hamqth_data = self._get_hamqth_data_for_callsign(call)
hamqth_data = self._get_hamqth_data_for_callsign(call, credentials)
if hamqth_data and "cq" in hamqth_data:
cqz = hamqth_data["cq"]
# Couldn't get anything from HamQTH database, try Clublog data
@@ -299,7 +328,7 @@ class LookupHelper:
cqz = dxcc_data["cq"][0]
return cqz
def infer_itu_zone_from_callsign(self, call):
def infer_itu_zone_from_callsign(self, call, credentials=None):
"""Infer a ITU zone from a callsign"""
try:
@@ -309,12 +338,12 @@ class LookupHelper:
ituz = None
# Couldn't get anything from basic call info database, try QRZ.com
if not ituz:
qrz_data = self._get_qrz_data_for_callsign(call)
qrz_data = self._get_qrz_data_for_callsign(call, credentials)
if qrz_data and "ituz" in qrz_data:
ituz = qrz_data["ituz"]
# Couldn't get anything from QRZ.com database, try HamQTH
if not ituz:
hamqth_data = self._get_hamqth_data_for_callsign(call)
hamqth_data = self._get_hamqth_data_for_callsign(call, credentials)
if hamqth_data and "itu" in hamqth_data:
ituz = hamqth_data["itu"]
# Couldn't get anything from HamQTH database, Clublog doesn't provide this, so try DXCC data
@@ -330,31 +359,31 @@ class LookupHelper:
return self._dxcc_data[dxcc]["flag"] if dxcc in self._dxcc_data else None
def infer_name_from_callsign_online_lookup(self, call):
def infer_name_from_callsign_online_lookup(self, call, credentials=None):
"""Infer an operator name from a callsign (requires QRZ.com/HamQTH)"""
data = self._get_qrz_data_for_callsign(call)
data = self._get_qrz_data_for_callsign(call, credentials)
if data and "fname" in data:
name = data["fname"]
if "name" in data:
name = name + " " + data["name"]
return name
data = self._get_hamqth_data_for_callsign(call)
data = self._get_hamqth_data_for_callsign(call, credentials)
if data and "nick" in data:
return data["nick"]
else:
return None
def infer_latlon_from_callsign_online_lookup(self, call):
def infer_latlon_from_callsign_online_lookup(self, call, credentials=None):
"""Infer a latitude and longitude from a callsign (requires QRZ.com/HamQTH)
Coordinates that look default are rejected (apologies if your position really is 0,0, enjoy your voyage)"""
data = self._get_qrz_data_for_callsign(call)
data = self._get_qrz_data_for_callsign(call, credentials)
if data and "latitude" in data and "longitude" in data and (
float(data["latitude"]) != 0 or float(data["longitude"]) != 0) and -89.9 < float(
data["latitude"]) < 89.9:
return [float(data["latitude"]), float(data["longitude"])]
data = self._get_hamqth_data_for_callsign(call)
data = self._get_hamqth_data_for_callsign(call, credentials)
if data and "latitude" in data and "longitude" in data and (
float(data["latitude"]) != 0 or float(data["longitude"]) != 0) and -89.9 < float(
data["latitude"]) < 89.9:
@@ -362,28 +391,28 @@ class LookupHelper:
else:
return None
def infer_grid_from_callsign_online_lookup(self, call):
def infer_grid_from_callsign_online_lookup(self, call, credentials=None):
"""Infer a grid locator from a callsign (requires QRZ.com/HamQTH).
Grids that look default are rejected (apologies if your grid really is AA00aa, enjoy your research)"""
data = self._get_qrz_data_for_callsign(call)
data = self._get_qrz_data_for_callsign(call, credentials)
if data and "locator" in data and data["locator"].upper() != "AA00" and data["locator"].upper() != "AA00AA" and \
data["locator"].upper() != "AA00AA00":
return data["locator"]
data = self._get_hamqth_data_for_callsign(call)
data = self._get_hamqth_data_for_callsign(call, credentials)
if data and "grid" in data and data["grid"].upper() != "AA00" and data["grid"].upper() != "AA00AA" and data[
"grid"].upper() != "AA00AA00":
return data["grid"]
else:
return None
def infer_qth_from_callsign_online_lookup(self, call):
def infer_qth_from_callsign_online_lookup(self, call, credentials=None):
"""Infer a textual QTH from a callsign (requires QRZ.com/HamQTH)"""
data = self._get_qrz_data_for_callsign(call)
data = self._get_qrz_data_for_callsign(call, credentials)
if data and "addr2" in data:
return data["addr2"]
data = self._get_hamqth_data_for_callsign(call)
data = self._get_hamqth_data_for_callsign(call, credentials)
if data and "qth" in data:
return data["qth"]
else:
@@ -422,79 +451,116 @@ class LookupHelper:
logging.debug("Invalid lat/lon received for DXCC")
return grid
def _get_qrz_data_for_callsign(self, call):
"""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, credentials):
"""Utility method to get QRZ.com data from cache if possible, if not get it from the API and cache it.
Returns None immediately if no credentials are provided."""
# Fetch from cache if we can, otherwise fetch from the API and cache it
# Return from cache if available (a cached None means 'not found in QRZ')
if call in self._qrz_callsign_data_cache:
return self._qrz_callsign_data_cache.get(call)
elif self._qrz_available:
# Obtain session key from credentials
session_key = None
if credentials and credentials.qrz_session_key:
session_key = credentials.qrz_session_key
elif credentials and credentials.qrz_username and credentials.qrz_password:
try:
data = self._lookup_lib_qrz.lookup_callsign(callsign=call)
self._qrz_callsign_data_cache.add(call, data, expire=604800) # 1 week in seconds
return data
except (KeyError, ValueError):
# 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, ValueError):
# 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
login_response = self._qrz_session_cache.get(
self._qrz_base_url + "?username=" + urllib.parse.quote_plus(credentials.qrz_username) +
"&password=" + urllib.parse.quote_plus(credentials.qrz_password) + "&agent=spothole",
headers=HTTP_HEADERS).content
login_data = xmltodict.parse(login_response)
session = login_data.get("QRZDatabase", {}).get("Session", {})
if "Key" in session:
session_key = session["Key"]
else:
logging.warning("QRZ.com login details incorrect, failed to look up with QRZ.")
return None
except Exception:
# General exception like a timeout when communicating with QRZ. Return None this time, but don't cache
# that, so we can try again next time.
logging.error("Exception when looking up QRZ data")
logging.error("Exception when getting QRZ.com session key")
return None
else:
if not session_key:
return None
def _get_hamqth_data_for_callsign(self, call):
"""Utility method to get HamQTH data from cache if possible, if not get it from the API and cache it"""
# Try the call as given, then fall back to the base call (strips /P, /M etc.)
calls_to_try = [call]
home_call = callinfo.Callinfo.get_homecall(call)
if home_call != call:
calls_to_try.append(home_call)
# Fetch from cache if we can, otherwise fetch from the API and cache it
for lookup_call in calls_to_try:
try:
lookup_response = requests.get(
self._qrz_base_url + "?s=" + session_key + "&callsign=" + urllib.parse.quote_plus(lookup_call),
headers=HTTP_HEADERS, timeout=10).content
raw = xmltodict.parse(lookup_response).get("QRZDatabase", {}).get("Callsign")
if raw:
data = _normalize_qrz_data(raw)
self._qrz_callsign_data_cache.add(call, data, expire=604800) # 1 week in seconds
return data
except (KeyError, ValueError):
continue
except Exception:
logging.error("Exception when looking up QRZ data")
return None
# Not found in QRZ; cache None so we don't keep retrying
self._qrz_callsign_data_cache.add(call, None, expire=604800) # 1 week in seconds
return None
def _get_hamqth_data_for_callsign(self, call, credentials):
"""Utility method to get HamQTH data from cache if possible, if not get it from the API and cache it.
Returns None immediately if no credentials are provided."""
# Return from cache if available
if call in self._hamqth_callsign_data_cache:
return self._hamqth_callsign_data_cache.get(call)
elif self._hamqth_available:
# Obtain session ID from credentials
session_id = None
if credentials and credentials.hamqth_session_id:
session_id = credentials.hamqth_session_id
elif credentials and credentials.hamqth_username and credentials.hamqth_password:
try:
# First we need to log in and get a session token.
session_data = self._hamqth_session_lookup_cache.get(
self._hamqth_base_url + "?u=" + urllib.parse.quote_plus(config["hamqth-username"]) +
"&p=" + urllib.parse.quote_plus(config["hamqth-password"]), headers=HTTP_HEADERS).content
self._hamqth_base_url + "?u=" + urllib.parse.quote_plus(credentials.hamqth_username) +
"&p=" + urllib.parse.quote_plus(credentials.hamqth_password), headers=HTTP_HEADERS).content
dict_data = xmltodict.parse(session_data)
if "session_id" in dict_data["HamQTH"]["session"]:
session_id = dict_data["HamQTH"]["session"]["session_id"]
# Now look up the actual data.
try:
lookup_data = SEMI_STATIC_URL_DATA_CACHE.get(
self._hamqth_base_url + "?id=" + session_id + "&callsign=" + urllib.parse.quote_plus(
call) + "&prg=" + HAMQTH_PRG, headers=HTTP_HEADERS).content
data = xmltodict.parse(lookup_data)["HamQTH"]["search"]
self._hamqth_callsign_data_cache.add(call, data, expire=604800) # 1 week in seconds
return data
except (KeyError, ValueError):
# HamQTH had no info for the call, but maybe it had prefixes or suffixes. Try again with the base call.
try:
lookup_data = SEMI_STATIC_URL_DATA_CACHE.get(
self._hamqth_base_url + "?id=" + session_id + "&callsign=" + urllib.parse.quote_plus(
callinfo.Callinfo.get_homecall(call)) + "&prg=" + HAMQTH_PRG,
headers=HTTP_HEADERS).content
data = xmltodict.parse(lookup_data)["HamQTH"]["search"]
self._hamqth_callsign_data_cache.add(call, data, expire=604800) # 1 week in seconds
return data
except (KeyError, ValueError):
# HamQTH had no info for the call, that's OK. Cache a None so we don't try to look this up again
self._hamqth_callsign_data_cache.add(call, None, expire=604800) # 1 week in seconds
return None
else:
logging.warning("HamQTH login details incorrect, failed to look up with HamQTH.")
except:
return None
except Exception:
logging.error("Exception when getting HamQTH session ID")
return None
if not session_id:
return None
# Try the call as given, then fall back to the base call (strips /P, /M etc.)
calls_to_try = [call]
home_call = callinfo.Callinfo.get_homecall(call)
if home_call != call:
calls_to_try.append(home_call)
for lookup_call in calls_to_try:
try:
lookup_data = SEMI_STATIC_URL_DATA_CACHE.get(
self._hamqth_base_url + "?id=" + session_id + "&callsign=" + urllib.parse.quote_plus(
lookup_call) + "&prg=" + HAMQTH_PRG, headers=HTTP_HEADERS).content
data = xmltodict.parse(lookup_data)["HamQTH"]["search"]
self._hamqth_callsign_data_cache.add(call, data, expire=604800) # 1 week in seconds
return data
except (KeyError, ValueError):
continue
except Exception:
logging.error("Exception when looking up HamQTH data")
return None
# Not found in HamQTH; cache None so we don't keep retrying
self._hamqth_callsign_data_cache.add(call, None, expire=604800) # 1 week in seconds
return None
def _get_clublog_api_data_for_callsign(self, call):
@@ -551,6 +617,7 @@ class LookupHelper:
"""Shutdown method to close down any caches neatly."""
self._qrz_callsign_data_cache.close()
self._hamqth_callsign_data_cache.close()
self._clublog_callsign_data_cache.close()
+10
View File
@@ -143,6 +143,16 @@ def populate_sig_ref_info(sig_ref):
if not sig_ref.name:
sig_ref.name = sig_ref.id
sig_ref.url = "https://wwtota.com/seznam/karta_rozhledny.php?ref=" + sig_ref.name
elif sig.upper() == "TILES":
# Tiles on the Air just uses Maidenhead 6-digit squares, so ID, Name and Grid are all the same
if not sig_ref.name:
sig_ref.name = sig_ref.id
if not sig_ref.grid:
sig_ref.grid = sig_ref.id
if sig_ref.grid and not sig_ref.latitude:
ll = locator_to_latlong(sig_ref.grid)
sig_ref.latitude = ll[0]
sig_ref.longitude = ll[1]
elif sig.upper() == "WAB" or sig.upper() == "WAI":
ll = wab_wai_square_to_lat_lon(ref_id)
if ll:
+20 -16
View File
@@ -61,7 +61,7 @@ class Alert:
# The ID the source gave it, if any.
source_id: str = None
def infer_missing(self):
def infer_missing(self, credentials=None):
"""Infer missing parameters where possible"""
# If we somehow don't have a start time, set it to zero so it sorts off the bottom of any list but
@@ -84,15 +84,15 @@ class Alert:
# DX country, continent, zones etc. from callsign. CQ/ITU zone are better looked up with a location but we don't
# have a real location for alerts.
if self.dx_calls and self.dx_calls[0] and not self.dx_country:
self.dx_country = lookup_helper.infer_country_from_callsign(self.dx_calls[0])
self.dx_country = lookup_helper.infer_country_from_callsign(self.dx_calls[0], credentials)
if self.dx_calls and self.dx_calls[0] and not self.dx_continent:
self.dx_continent = lookup_helper.infer_continent_from_callsign(self.dx_calls[0])
self.dx_continent = lookup_helper.infer_continent_from_callsign(self.dx_calls[0], credentials)
if self.dx_calls and self.dx_calls[0] and not self.dx_cq_zone:
self.dx_cq_zone = lookup_helper.infer_cq_zone_from_callsign(self.dx_calls[0])
self.dx_cq_zone = lookup_helper.infer_cq_zone_from_callsign(self.dx_calls[0], credentials)
if self.dx_calls and self.dx_calls[0] and not self.dx_itu_zone:
self.dx_itu_zone = lookup_helper.infer_itu_zone_from_callsign(self.dx_calls[0])
self.dx_itu_zone = lookup_helper.infer_itu_zone_from_callsign(self.dx_calls[0], credentials)
if self.dx_calls and self.dx_calls[0] and not self.dx_dxcc_id:
self.dx_dxcc_id = lookup_helper.infer_dxcc_id_from_callsign(self.dx_calls[0])
self.dx_dxcc_id = lookup_helper.infer_dxcc_id_from_callsign(self.dx_calls[0], credentials)
if self.dx_dxcc_id and not self.dx_flag:
self.dx_flag = lookup_helper.get_flag_for_dxcc(self.dx_dxcc_id)
@@ -108,21 +108,25 @@ class Alert:
if self.sig_refs and len(self.sig_refs) > 0 and self.sig_refs[0] and not self.sig:
self.sig = self.sig_refs[0].sig
# DX operator details lookup, using QRZ.com. This should be the last resort compared to taking the data from
# the actual alertting 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.
if self.dx_calls and not self.dx_names:
self.dx_names = list(map(lambda c: lookup_helper.infer_name_from_callsign_online_lookup(c), self.dx_calls))
# 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
# apart from that they were retrieved from the API at different times. Note that the simple Python hash()
# function includes a seed randomly generated at runtime; this is therefore not consistent between runs. But we
# use diskcache to store our data between runs, so we use SHA256 which does not include this random element.
self_copy = copy.deepcopy(self)
self_copy.received_time = 0
self_copy.received_time_iso = ""
self.id = hashlib.sha256(str(self_copy).encode("utf-8")).hexdigest()
# The ID is computed before the online lookups below so that it is stable regardless of whether credentials
# are provided, allowing the enriched API response to be matched to the stored alert by ID.
if not self.id:
self_copy = copy.deepcopy(self)
self_copy.received_time = 0
self_copy.received_time_iso = ""
self.id = hashlib.sha256(str(self_copy).encode("utf-8")).hexdigest()
# DX operator details lookup, using QRZ.com/HamQTH. This should be the last resort compared to taking the data
# from the actual alerting 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.
if self.dx_calls and not self.dx_names:
self.dx_names = list(
map(lambda c: lookup_helper.infer_name_from_callsign_online_lookup(c, credentials), self.dx_calls))
def to_json(self):
"""JSON serialise"""
+27
View File
@@ -0,0 +1,27 @@
from dataclasses import dataclass
@dataclass
class LookupCredentials:
"""Per-request credentials for QRZ.com and HamQTH online callsign lookups."""
qrz_username: str = ""
qrz_password: str = ""
qrz_session_key: str = "" # alternative to username/password
hamqth_username: str = ""
hamqth_password: str = ""
hamqth_session_id: str = "" # alternative to username/password
def extract_credentials(query_params):
"""Build a LookupCredentials from HTTP query params; returns None if no usable credentials are present."""
creds = LookupCredentials(
qrz_username=query_params.get("qrz_username", ""),
qrz_password=query_params.get("qrz_password", ""),
qrz_session_key=query_params.get("qrz_session_key", ""),
hamqth_username=query_params.get("hamqth_username", ""),
hamqth_password=query_params.get("hamqth_password", ""),
hamqth_session_id=query_params.get("hamqth_session_id", ""),
)
has_qrz = creds.qrz_session_key or (creds.qrz_username and creds.qrz_password)
has_hamqth = creds.hamqth_session_id or (creds.hamqth_username and creds.hamqth_password)
return creds if (has_qrz or has_hamqth) else None
+39 -8
View File
@@ -5,7 +5,7 @@ from dataclasses import dataclass
# Each threshold-based table is a list of (min_value, description) pairs in descending order;
# the first entry whose threshold the value meets or exceeds is used.
BLACKOUT_DESCRIPTIONS = {
XRAY_CLASS_DESCRIPTIONS = {
"X": "Wide area HF radio blackout across sunlit side",
"M": "Occasional loss of HF communications on sunlit side",
"C": "Low absorption of HF signals on sunlit side",
@@ -71,6 +71,28 @@ ELECTRON_FLUX_DESCRIPTIONS = [
]
def _xray_blackout_scale(xray):
"""Return the NOAA Radio Blackout scale number (R0-R5) for the given X-ray flux class string
(e.g. "M4.5", "X12")."""
if not xray or len(xray) < 2:
return 0
letter = xray[0].upper()
try:
number = float(xray[1:])
except ValueError:
return 0
if letter == 'M':
return 1 if number < 5 else 2
if letter == 'X':
if number < 10:
return 3
if number < 20:
return 4
return 5
return 0
def _lookup_by_threshold(value, table, default=None):
"""Return the description from a threshold table for the given numeric value.
The table is a list of (min_value, description) pairs in descending order."""
@@ -108,7 +130,7 @@ class SolarConditions:
# K-index (3-hour geomagnetic activity)
k_index: int = None
# X-ray flux class, e.g. "B2.3", "C1.0"
x_ray: str = None
xray: str = None
# Proton flux
proton_flux: int = None
# Electron flux
@@ -131,10 +153,20 @@ class SolarConditions:
hf_conditions: dict = None
# VHF propagation conditions, keyed by condition name
vhf_conditions: dict = None
# NOAA Kp index 3-day forecast, keyed by UNIX timestamp of the start of each 3-hour UTC period
k_index_forecast: dict = None
# NOAA Solar Radiation Storm (S1 or greater) probability forecast, keyed by UNIX timestamp of start of day UTC
solar_storm_forecast: dict = None
# NOAA Radio Blackout (R1-R2) probability forecast, keyed by UNIX timestamp of start of day UTC
blackout_forecast_r1r2: dict = None
# NOAA Radio Blackout (R3 or greater) probability forecast, keyed by UNIX timestamp of start of day UTC
blackout_forecast_r3_or_greater: dict = None
# Derived values (populated by infer_descriptions())
# HF radio blackout risk description, derived from x_ray
blackout_desc: str = None
# HF radio blackout risk description, derived from xray
xray_desc: str = None
# HF radio blackout scale number (R0-R5), derived from xray
radio_blackout_scale: int = None
# Solar radiation storm level description, derived from proton_flux
proton_flux_desc: str = None
# Solar radiation storm scale number (S0-S5), derived from proton_flux
@@ -151,10 +183,9 @@ class SolarConditions:
def infer_descriptions(self):
"""Populate derived text description fields from the current numeric/raw field values."""
# blackout_desc: use the X-ray flux class letter (first character of x_ray)
if self.x_ray and len(self.x_ray) > 0:
self.blackout_desc = BLACKOUT_DESCRIPTIONS.get(self.x_ray[0].upper())
if self.xray and len(self.xray) > 0:
self.xray_desc = XRAY_CLASS_DESCRIPTIONS.get(self.xray[0].upper())
self.radio_blackout_scale = _xray_blackout_scale(self.xray)
self.proton_flux_desc = _lookup_by_threshold(self.proton_flux, PROTON_FLUX_DESCRIPTIONS)
self.solar_storm_scale = _lookup_by_threshold(self.proton_flux, SOLAR_STORM_SCALES)
self.geomag_storm_desc = _lookup_by_threshold(self.k_index, GEOMAG_STORM_DESCRIPTIONS)
+44 -36
View File
@@ -12,8 +12,9 @@ from pyhamtools.locator import locator_to_latlong, latlong_to_locator
from core.config import MAX_SPOT_AGE
from core.constants import MODE_ALIASES
from core.geo_utils import lat_lon_to_cq_zone, lat_lon_to_itu_zone
from core.lookup_helper import lookup_helper, infer_band_from_freq, infer_mode_from_comment, infer_mode_from_frequency, \
infer_mode_type_from_mode
from core.lookup_helper import lookup_helper, infer_band_from_freq, infer_mode_from_comment, \
infer_mode_from_frequency, infer_mode_type_from_mode
from data.lookup_credentials import LookupCredentials
from core.sig_utils import populate_sig_ref_info, ANY_SIG_REGEX, get_ref_regex_for_sig
from data.sig_ref import SIGRef
@@ -131,7 +132,7 @@ class Spot:
# The ID the source gave it, if any.
source_id: str = None
def infer_missing(self):
def infer_missing(self, credentials=None):
"""Infer missing parameters where possible"""
# If we somehow don't have a spot time, set it to zero so it sorts off the bottom of any list but
@@ -158,11 +159,11 @@ class Spot:
# DX country, continent etc. from callsign
if self.dx_call and not self.dx_country:
self.dx_country = lookup_helper.infer_country_from_callsign(self.dx_call)
self.dx_country = lookup_helper.infer_country_from_callsign(self.dx_call, credentials)
if self.dx_call and not self.dx_continent:
self.dx_continent = lookup_helper.infer_continent_from_callsign(self.dx_call)
self.dx_continent = lookup_helper.infer_continent_from_callsign(self.dx_call, credentials)
if self.dx_call and not self.dx_dxcc_id:
self.dx_dxcc_id = lookup_helper.infer_dxcc_id_from_callsign(self.dx_call)
self.dx_dxcc_id = lookup_helper.infer_dxcc_id_from_callsign(self.dx_call, credentials)
if self.dx_dxcc_id and not self.dx_flag:
self.dx_flag = lookup_helper.get_flag_for_dxcc(self.dx_dxcc_id)
@@ -192,11 +193,11 @@ class Spot:
if self.de_call and any(char.isdigit() for char in self.de_call) and not (
self.de_call.startswith("T2") and self.source == "APRS-IS"):
if not self.de_country:
self.de_country = lookup_helper.infer_country_from_callsign(self.de_call)
self.de_country = lookup_helper.infer_country_from_callsign(self.de_call, credentials)
if not self.de_continent:
self.de_continent = lookup_helper.infer_continent_from_callsign(self.de_call)
self.de_continent = lookup_helper.infer_continent_from_callsign(self.de_call, credentials)
if not self.de_dxcc_id:
self.de_dxcc_id = lookup_helper.infer_dxcc_id_from_callsign(self.de_call)
self.de_dxcc_id = lookup_helper.infer_dxcc_id_from_callsign(self.de_call, credentials)
if self.de_dxcc_id and not self.de_flag:
self.de_flag = lookup_helper.get_flag_for_dxcc(self.de_dxcc_id)
@@ -306,27 +307,40 @@ class Spot:
if self.comment and not self.qrt:
self.qrt = "QRT" in self.comment.upper()
# 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 one from the park reference they're at.
# 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
# apart from that they were retrieved from the API at different times. Note that the simple Python hash()
# function includes a seed randomly generated at runtime; this is therefore not consistent between runs. But we
# use diskcache to store our data between runs, so we use SHA256 which does not include this random element.
# The ID is computed before the online lookups below so that it is stable regardless of whether credentials
# are provided, allowing the enriched API response to be matched to the stored spot by ID.
if not self.id:
self_copy = copy.deepcopy(self)
self_copy.received_time = 0
self_copy.received_time_iso = ""
self.id = hashlib.sha256(str(self_copy).encode("utf-8")).hexdigest()
# DX operator details lookup, using QRZ.com/HamQTH. 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 one from the park reference they're at.
if self.dx_call and not self.dx_name:
self.dx_name = lookup_helper.infer_name_from_callsign_online_lookup(self.dx_call)
self.dx_name = lookup_helper.infer_name_from_callsign_online_lookup(self.dx_call, credentials)
if self.dx_call and not self.dx_latitude:
latlon = lookup_helper.infer_latlon_from_callsign_online_lookup(self.dx_call)
latlon = lookup_helper.infer_latlon_from_callsign_online_lookup(self.dx_call, credentials)
if latlon:
self.dx_latitude = latlon[0]
self.dx_longitude = latlon[1]
self.dx_grid = lookup_helper.infer_grid_from_callsign_online_lookup(self.dx_call)
self.dx_grid = lookup_helper.infer_grid_from_callsign_online_lookup(self.dx_call, credentials)
self.dx_location_source = "HOME QTH"
# Determine a "QTH" string. If we have a SIG ref, pick the first one and turn it into a suitable stirng,
# Determine a "QTH" string. If we have a SIG ref, pick the first one and turn it into a suitable string,
# otherwise see what they have set on an online lookup service.
if self.sig_refs and len(self.sig_refs) > 0:
self.dx_qth = self.sig_refs[0].id
if self.sig_refs[0].name:
self.dx_qth = self.dx_qth + " " + self.sig_refs[0].name
else:
self.dx_qth = lookup_helper.infer_qth_from_callsign_online_lookup(self.dx_call)
self.dx_qth = lookup_helper.infer_qth_from_callsign_online_lookup(self.dx_call, credentials)
# Last resort for getting a DX position, use the DXCC entity.
if self.dx_call and not self.dx_latitude:
@@ -337,23 +351,27 @@ class Spot:
self.dx_grid = lookup_helper.infer_grid_from_callsign_dxcc(self.dx_call)
self.dx_location_source = "DXCC"
# It looks like we can sometimes get a string into lat/lon, so reject that before we try looking anything up
# It looks like we can sometimes get a string into lat/lon, so try to parse as float, reject if not valid
if isinstance(self.dx_latitude, str) or isinstance(self.dx_longitude, str):
logging.warning("Received strings in lat/lon (" + str(self.dx_latitude) + ", " + str(self.dx_longitude) + ") for call " + self.dx_call + ", rejecting it")
self.dx_latitude = None
self.dx_longitude = None
try:
self.dx_latitude = float(self.dx_latitude)
self.dx_longitude = float(self.dx_longitude)
except (TypeError, ValueError):
logging.warning("Received non-numeric strings in lat/lon (" + str(self.dx_latitude) + ", " + str(self.dx_longitude) + ") for call " + self.dx_call + ", rejecting it")
self.dx_latitude = None
self.dx_longitude = None
# CQ and ITU zone lookup, preferably from location but failing that, from callsign
if not self.dx_cq_zone:
if self.dx_latitude:
self.dx_cq_zone = lat_lon_to_cq_zone(self.dx_latitude, self.dx_longitude)
elif self.dx_call:
self.dx_cq_zone = lookup_helper.infer_cq_zone_from_callsign(self.dx_call)
self.dx_cq_zone = lookup_helper.infer_cq_zone_from_callsign(self.dx_call, credentials)
if not self.dx_itu_zone:
if self.dx_latitude:
self.dx_itu_zone = lat_lon_to_itu_zone(self.dx_latitude, self.dx_longitude)
elif self.dx_call:
self.dx_itu_zone = lookup_helper.infer_itu_zone_from_callsign(self.dx_call)
self.dx_itu_zone = lookup_helper.infer_itu_zone_from_callsign(self.dx_call, credentials)
# DX Location is "good" if it is from a spot, or from QRZ if the callsign doesn't contain a slash, so the operator
# is likely at home.
@@ -365,13 +383,13 @@ class Spot:
# DE with no digits and APRS servers starting "T2" are not things we can look up location for
if self.de_call and any(char.isdigit() for char in self.de_call) and not (
self.de_call.startswith("T2") and self.source == "APRS-IS"):
# DE operator position lookup, using QRZ.com.
# DE operator position lookup, using QRZ.com/HamQTH.
if not self.de_latitude:
latlon = lookup_helper.infer_latlon_from_callsign_online_lookup(self.de_call)
latlon = lookup_helper.infer_latlon_from_callsign_online_lookup(self.de_call, credentials)
if latlon:
self.de_latitude = latlon[0]
self.de_longitude = latlon[1]
self.de_grid = lookup_helper.infer_grid_from_callsign_online_lookup(self.de_call)
self.de_grid = lookup_helper.infer_grid_from_callsign_online_lookup(self.de_call, credentials)
# Last resort for getting a DE position, use the DXCC entity.
if not self.de_latitude:
@@ -381,16 +399,6 @@ class Spot:
self.de_longitude = latlon[1]
self.de_grid = lookup_helper.infer_grid_from_callsign_dxcc(self.de_call)
# 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
# apart from that they were retrieved from the API at different times. Note that the simple Python hash()
# function includes a seed randomly generated at runtime; this is therefore not consistent between runs. But we
# use diskcache to store our data between runs, so we use SHA256 which does not include this random element.
self_copy = copy.deepcopy(self)
self_copy.received_time = 0
self_copy.received_time_iso = ""
self.id = hashlib.sha256(str(self_copy).encode("utf-8")).hexdigest()
def to_json(self):
"""JSON serialise"""
+13
View File
@@ -0,0 +1,13 @@
ref,lat,lon
T-01,50.3636495,7.5584857
T-02,50.3636495,7.5584857
T-03,50.3636495,7.5584857
T-11,50.3636495,7.5584857
T-13,50.3636495,7.5584857
T-14,50.3636495,7.5584857
T-21,50.3636495,7.5584857
T-31,50.3636495,7.5584857
T-33,50.3636495,7.5584857
T-34,50.3636495,7.5584857
T-41,50.3636495,7.5584857
T-51,50.3636495,7.5584857
1 ref lat lon
2 T-01 50.3636495 7.5584857
3 T-02 50.3636495 7.5584857
4 T-03 50.3636495 7.5584857
5 T-11 50.3636495 7.5584857
6 T-13 50.3636495 7.5584857
7 T-14 50.3636495 7.5584857
8 T-21 50.3636495 7.5584857
9 T-31 50.3636495 7.5584857
10 T-33 50.3636495 7.5584857
11 T-34 50.3636495 7.5584857
12 T-41 50.3636495 7.5584857
13 T-51 50.3636495 7.5584857
+21 -2
View File
@@ -1,3 +1,4 @@
import copy
import json
import logging
from datetime import datetime
@@ -9,6 +10,8 @@ import tornado_eventsource.handler
from core.prometheus_metrics_handler import api_requests_counter
from core.utils import serialize_everything, empty_queue
from data.lookup_credentials import extract_credentials
SSE_HANDLER_MAX_QUEUE_SIZE = 100
SSE_HANDLER_QUEUE_CHECK_INTERVAL = 5000
@@ -21,6 +24,15 @@ class APIAlertsHandler(tornado.web.RequestHandler):
self._alerts = alerts
self._web_server_metrics = web_server_metrics
@staticmethod
def _enrich(alerts, credentials):
enriched = []
for alert in alerts:
alert_copy = copy.deepcopy(alert)
alert_copy.infer_missing(credentials)
enriched.append(alert_copy)
return enriched
def get(self):
try:
# Metrics
@@ -33,8 +45,11 @@ class APIAlertsHandler(tornado.web.RequestHandler):
# reduce that to just the first entry, and convert bytes to string
query_params = {k: v[0].decode("utf-8") for k, v in self.request.arguments.items()}
# Fetch all alerts matching the query
# Fetch all alerts matching the query, then optionally enrich with online data
credentials = extract_credentials(query_params)
data = get_alert_list_with_filters(self._alerts, query_params)
if credentials:
data = self._enrich(data, credentials)
self.write(json.dumps(data, default=serialize_everything))
self.set_status(200)
except ValueError as e:
@@ -73,6 +88,7 @@ class APIAlertsStreamHandler(tornado_eventsource.handler.EventSourceHandler):
# request.arguments contains lists for each param key because technically the client can supply multiple,
# reduce that to just the first entry, and convert bytes to string
self._query_params = {k: v[0].decode("utf-8") for k, v in self.request.arguments.items()}
self._credentials = extract_credentials(self._query_params)
# Create a alert queue and add it to the web server's list. The web server will fill this when alerts arrive
self._alert_queue = Queue(maxsize=SSE_HANDLER_MAX_QUEUE_SIZE)
@@ -110,6 +126,9 @@ class APIAlertsStreamHandler(tornado_eventsource.handler.EventSourceHandler):
alert = self._alert_queue.get()
# If the new alert matches our param filters, send it to the client. If not, ignore it.
if alert_allowed_by_query(alert, self._query_params):
if self._credentials:
alert = copy.deepcopy(alert)
alert.infer_missing(self._credentials)
self.write_message(msg=json.dumps(alert, default=serialize_everything))
if self._alert_queue not in self._sse_alert_queues:
@@ -147,7 +166,7 @@ def alert_allowed_by_query(alert, query):
for k in query.keys():
match k:
case "received_since":
since = datetime.fromtimestamp(int(query.get(k)), pytz.UTC)
since = datetime.fromtimestamp(float(query.get(k)), pytz.UTC)
if not alert.received_time or alert.received_time <= since:
return False
case "max_duration":
+3 -1
View File
@@ -11,6 +11,7 @@ from core.geo_utils import lat_lon_for_grid_sw_corner_plus_size, lat_lon_to_cq_z
from core.prometheus_metrics_handler import api_requests_counter
from core.sig_utils import get_ref_regex_for_sig, populate_sig_ref_info
from core.utils import serialize_everything
from data.lookup_credentials import extract_credentials
from data.sig_ref import SIGRef
from data.spot import Spot
@@ -39,8 +40,9 @@ class APILookupCallHandler(tornado.web.RequestHandler):
if re.match(r"^[A-Z0-9/\-]*$", call):
# Take the callsign, make a "fake spot" so we can run infer_missing() on it, then repack the
# resulting data in the correct way for the API response.
credentials = extract_credentials(query_params)
fake_spot = Spot(dx_call=call)
fake_spot.infer_missing()
fake_spot.infer_missing(credentials)
data = {
"call": call,
"name": fake_spot.dx_name,
+21 -2
View File
@@ -1,3 +1,4 @@
import copy
import json
import logging
from datetime import datetime, timedelta
@@ -9,6 +10,8 @@ import tornado_eventsource.handler
from core.prometheus_metrics_handler import api_requests_counter
from core.utils import serialize_everything, empty_queue
from data.lookup_credentials import extract_credentials
SSE_HANDLER_MAX_QUEUE_SIZE = 1000
SSE_HANDLER_QUEUE_CHECK_INTERVAL = 5000
@@ -21,6 +24,15 @@ class APISpotsHandler(tornado.web.RequestHandler):
self._spots = spots
self._web_server_metrics = web_server_metrics
@staticmethod
def _enrich(spots, credentials):
enriched = []
for spot in spots:
spot_copy = copy.deepcopy(spot)
spot_copy.infer_missing(credentials)
enriched.append(spot_copy)
return enriched
def get(self):
try:
# Metrics
@@ -33,8 +45,11 @@ class APISpotsHandler(tornado.web.RequestHandler):
# reduce that to just the first entry, and convert bytes to string
query_params = {k: v[0].decode("utf-8") for k, v in self.request.arguments.items()}
# Fetch all spots matching the query
# Fetch all spots matching the query, then optionally enrich with online data
credentials = extract_credentials(query_params)
data = get_spot_list_with_filters(self._spots, query_params)
if credentials:
data = self._enrich(data, credentials)
self.write(json.dumps(data, default=serialize_everything))
self.set_status(200)
except ValueError as e:
@@ -75,6 +90,7 @@ class APISpotsStreamHandler(tornado_eventsource.handler.EventSourceHandler):
# request.arguments contains lists for each param key because technically the client can supply multiple,
# reduce that to just the first entry, and convert bytes to string
self._query_params = {k: v[0].decode("utf-8") for k, v in self.request.arguments.items()}
self._credentials = extract_credentials(self._query_params)
# Create a spot queue and add it to the web server's list. The web server will fill this when spots arrive
self._spot_queue = Queue(maxsize=SSE_HANDLER_MAX_QUEUE_SIZE)
@@ -112,6 +128,9 @@ class APISpotsStreamHandler(tornado_eventsource.handler.EventSourceHandler):
spot = self._spot_queue.get()
# If the new spot matches our param filters, send it to the client. If not, ignore it.
if spot_allowed_by_query(spot, self._query_params):
if self._credentials:
spot = copy.deepcopy(spot)
spot.infer_missing(self._credentials)
self.write_message(msg=json.dumps(spot, default=serialize_everything))
if self._spot_queue not in self._sse_spot_queues:
@@ -178,7 +197,7 @@ def spot_allowed_by_query(spot, query):
if not spot.time or spot.time <= since:
return False
case "received_since":
since = datetime.fromtimestamp(int(query.get(k)), pytz.UTC).timestamp()
since = datetime.fromtimestamp(float(query.get(k)), pytz.UTC).timestamp()
if not spot.received_time or spot.received_time <= since:
return False
case "source":
+5 -2
View File
@@ -11,9 +11,11 @@ from core.prometheus_metrics_handler import page_requests_counter
class PageTemplateHandler(tornado.web.RequestHandler):
"""Handler for all HTML pages generated from templates"""
def initialize(self, template_name, web_server_metrics):
def initialize(self, template_name, web_server_metrics, has_hamqsl=False, has_noaa_forecast=False):
self._template_name = template_name
self._web_server_metrics = web_server_metrics
self._has_hamqsl = has_hamqsl
self._has_noaa_forecast = has_noaa_forecast
def get(self):
# Metrics
@@ -24,4 +26,5 @@ class PageTemplateHandler(tornado.web.RequestHandler):
# Load named template, and provide variables used in templates
self.render(self._template_name + ".html", software_version=SOFTWARE_VERSION, allow_spotting=ALLOW_SPOTTING,
web_ui_options=WEB_UI_OPTIONS, baseurl = BASE_URL, current_path=self.request.path)
web_ui_options=WEB_UI_OPTIONS, baseurl=BASE_URL, current_path=self.request.path,
has_hamqsl=self._has_hamqsl, has_noaa_forecast=self._has_noaa_forecast)
+17 -15
View File
@@ -21,7 +21,7 @@ from server.handlers.pagetemplate import PageTemplateHandler
class WebServer:
"""Provides the public-facing web server."""
def __init__(self, spots, alerts, solar_conditions, status_data, port):
def __init__(self, spots, alerts, solar_conditions, status_data, solar_condition_providers, port):
"""Constructor"""
self._spots = spots
@@ -30,6 +30,7 @@ class WebServer:
self._sse_spot_queues = []
self._sse_alert_queues = []
self._status_data = status_data
self._solar_condition_providers = solar_condition_providers
self._port = port
self._shutdown_event = asyncio.Event()
self.web_server_metrics = {
@@ -53,6 +54,12 @@ class WebServer:
async def _start_inner(self):
"""Start method (async). Sets up the Tornado application."""
provider_classes = [type(p).__name__ for p in self._solar_condition_providers if p.enabled]
has_hamqsl = "HamQSL" in provider_classes
has_noaa_forecast = "NOAA3dayForecast" in provider_classes
page_opts = {"web_server_metrics": self.web_server_metrics, "has_hamqsl": has_hamqsl,
"has_noaa_forecast": has_noaa_forecast}
app = tornado.web.Application([
# Routes for API calls
(r"/api/v1/spots", APISpotsHandler, {"spots": self._spots, "web_server_metrics": self.web_server_metrics}),
@@ -74,20 +81,15 @@ class WebServer:
(r"/api/v1/lookup/grid", APILookupGridHandler, {"web_server_metrics": self.web_server_metrics}),
(r"/api/v1/spot", APISpotHandler, {"spots": self._spots, "web_server_metrics": self.web_server_metrics}),
# Routes for templated pages
(r"/", PageTemplateHandler, {"template_name": "spots", "web_server_metrics": self.web_server_metrics}),
(r"/map", PageTemplateHandler, {"template_name": "map", "web_server_metrics": self.web_server_metrics}),
(r"/bands", PageTemplateHandler, {"template_name": "bands", "web_server_metrics": self.web_server_metrics}),
(r"/alerts", PageTemplateHandler,
{"template_name": "alerts", "web_server_metrics": self.web_server_metrics}),
(r"/add-spot", PageTemplateHandler,
{"template_name": "add_spot", "web_server_metrics": self.web_server_metrics}),
(r"/conditions", PageTemplateHandler,
{"template_name": "conditions", "web_server_metrics": self.web_server_metrics}),
(r"/status", PageTemplateHandler,
{"template_name": "status", "web_server_metrics": self.web_server_metrics}),
(r"/about", PageTemplateHandler, {"template_name": "about", "web_server_metrics": self.web_server_metrics}),
(r"/apidocs", PageTemplateHandler,
{"template_name": "apidocs", "web_server_metrics": self.web_server_metrics}),
(r"/", PageTemplateHandler, {"template_name": "spots", **page_opts}),
(r"/map", PageTemplateHandler, {"template_name": "map", **page_opts}),
(r"/bands", PageTemplateHandler, {"template_name": "bands", **page_opts}),
(r"/alerts", PageTemplateHandler, {"template_name": "alerts", **page_opts}),
(r"/add-spot", PageTemplateHandler, {"template_name": "add_spot", **page_opts}),
(r"/conditions", PageTemplateHandler, {"template_name": "conditions", **page_opts}),
(r"/status", PageTemplateHandler, {"template_name": "status", **page_opts}),
(r"/about", PageTemplateHandler, {"template_name": "about", **page_opts}),
(r"/apidocs", PageTemplateHandler, {"template_name": "apidocs", **page_opts}),
# Route for Prometheus metrics
(r"/metrics", PrometheusMetricsHandler),
# Default route to serve from "webassets"
+1 -1
View File
@@ -86,7 +86,7 @@ class HamQSL(HTTPSolarConditionsProvider):
"sfi": int_val("solarflux"),
"a_index": int_val("aindex"),
"k_index": int_val("kindex"),
"x_ray": text("xray"),
"xray": text("xray"),
"sunspots": int_val("sunspots"),
"proton_flux": int_val("protonflux"),
"electron_flux": int_val("electonflux"),
@@ -0,0 +1,177 @@
import logging
import re
from datetime import datetime, timezone
from solarconditionsproviders.http_solar_conditions_provider import HTTPSolarConditionsProvider
POLL_INTERVAL = 10800 # Every 3 hours
URL = "https://services.swpc.noaa.gov/text/3-day-forecast.txt"
class NOAA3dayForecast(HTTPSolarConditionsProvider):
"""Solar conditions provider using the NOAA 3-day forecast text file. Parses the NOAA forecast and populates
corresponding fields in the solar conditions object.."""
def __init__(self, provider_config):
super().__init__(provider_config, URL, POLL_INTERVAL)
@staticmethod
def _parse_percentage_table(lines, section_header, year):
"""Find and parse a forecast table using percentages, identified by section_header. This is common to the lookup
of the solar storm and radio blackout forecast parsing."""
start_idx = None
for i, line in enumerate(lines):
if section_header in line:
start_idx = i
break
if start_idx is None:
logging.warning(f"NOAA 3-day forecast: could not find '{section_header}' section")
return None
# Find the date header line — the first line within the next few that contains month+day patterns
date_header_idx = None
for j in range(start_idx + 1, min(start_idx + 6, len(lines))):
if re.search(r'[A-Za-z]{3}\s+\d{2}', lines[j]):
date_header_idx = j
break
if date_header_idx is None:
logging.warning(f"NOAA 3-day forecast: could not find date header after '{section_header}'")
return None
date_matches = re.findall(r'([A-Za-z]{3})\s+(\d{2})', lines[date_header_idx])
if not date_matches:
logging.warning(f"NOAA 3-day forecast: no dates in header: {lines[date_header_idx]}")
return None
column_timestamps = []
for month_str, day_str in date_matches:
try:
dt = datetime.strptime(f"{day_str} {month_str} {year}", "%d %b %Y").replace(tzinfo=timezone.utc)
column_timestamps.append(dt.timestamp())
except ValueError:
logging.warning(f"NOAA 3-day forecast: could not parse date: {month_str} {day_str} {year}")
return None
# Parse data rows: each non-empty line should have a text label and percentage values
result = {}
for line in lines[date_header_idx + 1:]:
line_stripped = line.strip()
if not line_stripped:
if result:
break
continue
pct_matches = list(re.finditer(r'\b(\d+)%', line_stripped))
if not pct_matches:
if result:
break
continue
# Row label is everything before the first percentage value
row_label = line_stripped[:line_stripped.index(pct_matches[0].group())].strip()
row_data = {}
for j, match in enumerate(pct_matches):
if j >= len(column_timestamps):
break
row_data[column_timestamps[j]] = int(match.group(1))
if row_data:
result[row_label] = row_data
return result if result else None
def _http_response_to_solar_conditions(self, http_response):
if http_response.status_code != 200:
logging.warning("NOAA K-index forecast API returned HTTP " + str(http_response.status_code))
return None
lines = http_response.text.splitlines()
# Find the "NOAA Kp index breakdown" section header
start_idx = None
for i, line in enumerate(lines):
if "NOAA Kp index breakdown" in line:
start_idx = i
break
if start_idx is None:
logging.warning("NOAA K-index forecast: could not find 'NOAA Kp index breakdown' section")
return None
# Extract the year from the header line, e.g. "NOAA Kp index breakdown Apr 2-Apr 4, 2026"
header_line = lines[start_idx]
year_match = re.search(r'\b(\d{4})\b', header_line)
if not year_match:
logging.warning("NOAA K-index forecast: could not extract year from: " + header_line)
return None
year = int(year_match.group(1))
# Parse the column date headers on the next line, e.g. " Apr 02 Apr 03 Apr 04"
if start_idx + 1 >= len(lines):
logging.warning("NOAA K-index forecast: missing date header line")
return None
date_header_line = lines[start_idx + 2]
date_matches = re.findall(r'([A-Za-z]{3})\s+(\d{2})', date_header_line)
if not date_matches:
logging.warning("NOAA K-index forecast: could not parse date headers from: " + date_header_line)
return None
column_dates = []
for month_str, day_str in date_matches:
try:
column_dates.append(datetime.strptime(f"{day_str} {month_str} {year}", "%d %b %Y").date())
except ValueError:
logging.warning(f"NOAA K-index forecast: could not parse date: {month_str} {day_str} {year}")
return None
# Parse each data row, e.g. "00-03UT 2.00 3.00 2.00"
k_index_forecast = {}
for line in lines[start_idx + 3:]:
time_match = re.match(r'^(\d{2})-(\d{2})UT\s+(.*)', line.strip())
if not time_match:
if k_index_forecast:
break
continue
start_hour = int(time_match.group(1))
# Split on 2 or more spaces so that e.g. "5.67 (G2)" stays as one token per column
raw_values = re.split(r' {2,}', time_match.group(3).strip())
for i, val in enumerate(raw_values):
if i >= len(column_dates):
break
# Take only the leading numeric part, discarding any bracketed section
try:
kp = float(val.split()[0])
except (ValueError, IndexError):
continue
date = column_dates[i]
start_dt = datetime(date.year, date.month, date.day, start_hour, 0, 0, tzinfo=timezone.utc)
# Key the data dict by start time
key = start_dt.timestamp()
k_index_forecast[key] = kp
if not k_index_forecast:
logging.warning("NOAA K-index forecast: no data rows parsed")
return None
# Parse Solar Radiation Storm Forecast (single row: "S1 or greater")
solar_storm_forecast = None
radiation_table = self._parse_percentage_table(lines, "Solar Radiation Storm Forecast", year)
if radiation_table:
solar_storm_forecast = radiation_table.get("S1 or greater")
# Parse Radio Blackout Forecast (two rows: "R1-R2" and "R3 or greater")
blackout_forecast_r1r2 = None
blackout_forecast_r3_or_greater = None
blackout_table = self._parse_percentage_table(lines, "Radio Blackout Forecast", year)
if blackout_table:
blackout_forecast_r1r2 = blackout_table.get("R1-R2")
blackout_forecast_r3_or_greater = blackout_table.get("R3 or greater")
return {
"k_index_forecast": k_index_forecast,
"solar_storm_forecast": solar_storm_forecast,
"blackout_forecast_r1r2": blackout_forecast_r1r2,
"blackout_forecast_r3_or_greater": blackout_forecast_r3_or_greater,
}
+2 -1
View File
@@ -97,7 +97,8 @@ if __name__ == '__main__':
lookup_helper.start()
# Set up web server
web_server = WebServer(spots=spots, alerts=alerts, solar_conditions=solar_conditions, status_data=status_data, port=WEB_SERVER_PORT)
web_server = WebServer(spots=spots, alerts=alerts, solar_conditions=solar_conditions, status_data=status_data,
solar_condition_providers=solar_condition_providers, port=WEB_SERVER_PORT)
# Fetch, set up and start spot providers
for entry in config["spot-providers"]:
+50
View File
@@ -0,0 +1,50 @@
from datetime import datetime
from data.sig_ref import SIGRef
from data.spot import Spot
from spotproviders.http_spot_provider import HTTPSpotProvider
class Tiles(HTTPSpotProvider):
"""Spot provider for Tiles on the Air"""
POLL_INTERVAL_SEC = 120
SPOTS_URL = "https://icneuzxitdqtofutxbla.supabase.co/functions/v1/spots?active_hours=24"
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()["spots"]:
# Convert to our spot format
spot = Spot(source=self.name,
source_id=source_spot["id"],
dx_call=source_spot["call_sign"].upper(),
# No separate spotter callsign, assume all spots are self-spots
de_call=source_spot["call_sign"].upper(),
freq=float(strip_extra_decimal_points(source_spot["frequency"])) * 1000000,
mode=source_spot["mode"].upper(),
comment=source_spot["notes"],
sig="Tiles",
# Tiles spots can include POTA & SOTA references, but ignore those on the basis that we will get them separately from the POTA/SOTA providers anyway.
# Just take the grid reference itself as the single Tiles SIG reference.
sig_refs=[SIGRef(id=source_spot["maidenhead_grid"], sig="Tiles", name=source_spot["maidenhead_grid"])],
time=datetime.fromisoformat(source_spot["created_at"].replace("Z", "+00:00")).timestamp(),
dx_grid=source_spot["maidenhead_grid"],
dx_latitude=source_spot["latitude"],
dx_longitude=source_spot["longitude"])
# Add to our list. Don't worry about de-duping, removing old spots etc. at this point; other code will do
# that for us.
new_spots.append(spot)
return new_spots
# Utility function to keep the first decimal point in a given string but remove any others. Used to parse Tiles'
# strange frequency format where we can sometimes have e.g. "14.123.5".
def strip_extra_decimal_points(s):
parts = s.split('.', 1)
if len(parts) == 1:
return s
return parts[0] + '.' + parts[1].replace('.', '')
+1 -1
View File
@@ -47,7 +47,7 @@ class WOTA(HTTPSpotProvider):
desc_split = source_spot.description.split(". ")
freq_mode = desc_split[0].replace("Frequencies/modes:", "").strip()
freq_mode_split = re.split(r'[\-\s]+', freq_mode)
freq_hz = float(freq_mode_split[0]) * 1000000
freq_hz = float(freq_mode_split[0].replace("'",".")) * 1000000
mode = None
if len(freq_mode_split) > 1:
mode = freq_mode_split[1].upper()
+7 -5
View File
@@ -25,11 +25,12 @@
<h4 class="mt-4">What are "DX", "DE" and modes?</h4>
<p>In amateur radio terminology, the "DX" contact is the "interesting" one that is using the frequency shown and looking for callers. They might be on a remote island or just in a local park, but either way it's interesting enough that someone has "spotted" them. The callsign listed under "DE" is the person who entered the spot of the "DX" operator. "Modes" are the type of communication they are using. For example you might see "CW" which is Morse Code, or voice "modes" like SSB or FM, or more exotic "data" modes which are used for computer-to-computer communication.</p>
<h4 class="mt-4">What data sources are supported?</h4>
<p>Spothole can retrieve spots from: <a href="https://www.dxcluster.info/telnet/">Telnet-based DX clusters</a>, the <a href="https://www.reversebeacon.net/">Reverse Beacon Network (RBN)</a>, the <a href="https://www.aprs-is.net/">APRS Internet Service (APRS-IS)</a>, <a href="https://pota.app">POTA</a>, <a href="https://www.sota.org.uk/">SOTA</a>, <a href="https://wwff.co/">WWFF</a>, <a href="https://www.cqgma.org/">GMA</a>, <a href="https://wwbota.net/">WWBOTA</a>, <a href="http://www.hema.org.uk/">HEMA</a>, <a href="https://www.parksnpeaks.org/">Parks 'n' Peaks</a>, <a href="https://ontheair.nz">ZLOTA</a>, <a href="https://www.wota.org.uk/">WOTA</a>, <a href="https://llota.app">LLOTA</a>, <a href="https://wwtota.com">WWTOTA</a>, the <a href="https://ukpacketradio.network/">UK Packet Repeater Network</a>, and any site based on the <a href="https://github.com/nischu/xOTA">xOTA software by nischu</a>.</p>
<p>Spothole can retrieve spots from: <a href="https://www.dxcluster.info/telnet/">Telnet-based DX clusters</a>, the <a href="https://www.reversebeacon.net/">Reverse Beacon Network (RBN)</a>, the <a href="https://www.aprs-is.net/">APRS Internet Service (APRS-IS)</a>, <a href="https://pota.app">POTA</a>, <a href="https://www.sota.org.uk/">SOTA</a>, <a href="https://wwff.co/">WWFF</a>, <a href="https://www.cqgma.org/">GMA</a>, <a href="https://wwbota.net/">WWBOTA</a>, <a href="http://www.hema.org.uk/">HEMA</a>, <a href="https://www.parksnpeaks.org/">Parks 'n' Peaks</a>, <a href="https://ontheair.nz">ZLOTA</a>, <a href="https://www.wota.org.uk/">WOTA</a>, <a href="https://llota.app">LLOTA</a>, <a href="https://wwtota.com">WWTOTA</a>, <a href="https://tilesontheair.com/">Tiles on the Air</a>, the <a href="https://ukpacketradio.network/">UK Packet Repeater Network</a>, and any site based on the <a href="https://github.com/nischu/xOTA">xOTA software by nischu</a>.</p>
<p>Spothole can retrieve alerts from: <a href="https://www.ng3k.com/">NG3K</a>, <a href="https://pota.app">POTA</a>, <a href="https://www.sota.org.uk/">SOTA</a>, <a href="https://wwff.co/">WWFF</a>, <a href="https://www.parksnpeaks.org/">Parks 'n' Peaks</a>, <a href="https://www.wota.org.uk/">WOTA</a> and <a href="https://www.beachesontheair.com/">BOTA</a>.</p>
<p>Spothole can retrieve solar and propagation condition data from <a href="https://www.hamqsl.com">HamQSL</a>.</p>
<p>Spothole can also perform lookups for callsign data on behalf of the user from <a href="https://qrz.com">QRZ.com</a> and <a href="https://hamqth.com">HamQTH</a>.</p>
<p>Note that the server owner has not necessarily enabled all these data sources. In particular it is common to disable RBN, to avoid the server being swamped with FT8 traffic, and to disable APRS-IS and UK Packet Net so that the server only displays stations where there is likely to be an operator physically present for a QSO.</p>
<p>Between the various data sources, the following Special Interest Groups (SIGs) are supported: Parks on the Air (POTA), Summits on the Air (SOTA), Worldwide Flora & Fauna (WWFF), Global Mountain Activity (GMA), Worldwide Bunkers on the Air (WWBOTA), HuMPs Excluding Marilyns Award (HEMA), Islands on the Air (IOTA), Mills on the Air (MOTA), the Amateur Radio Lighthouse Socirty (ARLHS), International Lighthouse Lightship Weekend (ILLW), Silos on the Air (SIOTA), World Castles Award (WCA), New Zealand on the Air (ZLOTA), Keith Roget Memorial National Parks Award (KRMNPA), Wainwrights on the Air (WOTA), Beaches on the Air (BOTA), Lagos y Lagunas On the Air (LLOTA), Towers on the Air (WWTOTA), Worked All Britain (WAB), Worked All Ireland (WAI), and Toilets on the Air (TOTA).</p>
<p>Between the various data sources, the following Special Interest Groups (SIGs) are supported: Parks on the Air (POTA), Summits on the Air (SOTA), Worldwide Flora & Fauna (WWFF), Global Mountain Activity (GMA), Worldwide Bunkers on the Air (WWBOTA), HuMPs Excluding Marilyns Award (HEMA), Islands on the Air (IOTA), Mills on the Air (MOTA), the Amateur Radio Lighthouse Socirty (ARLHS), International Lighthouse Lightship Weekend (ILLW), Silos on the Air (SIOTA), World Castles Award (WCA), New Zealand on the Air (ZLOTA), Keith Roget Memorial National Parks Award (KRMNPA), Wainwrights on the Air (WOTA), Beaches on the Air (BOTA), Lagos y Lagunas On the Air (LLOTA), Towers on the Air (WWTOTA), Tiles on the Air, Worked All Britain (WAB), Worked All Ireland (WAI), and Toilets on the Air (TOTA).</p>
<p>As of the time of writing in November 2025, I think Spothole captures essentially all outdoor radio programmes that have a defined reference list, and almost certainly those that have a spotting/alerting API. If you know of one I've missed, please let me know!</p>
<h4 class="mt-4">Why can I filter spots by both SIG and Source? Isn't that basically the same thing?</h4>
<p>Mostly, but not quite. While POTA spots generally come from the POTA source and so on, there are a few exceptions:</p>
@@ -54,9 +55,10 @@
<h2 class="mt-4">Data Accuracy</h2>
<p>Please note that the data coming out of Spothole is only as good as the data going in. People mis-hear and make typos when spotting callsigns all the time. There are also plenty of cases where Spothole's data, particularly location data, may be inaccurate. For example, there are POTA parks that span multiple US states, countries that span multiple CQ zones, portable operators with no requirement to sign /P, etc. If you are doing something where accuracy is important, such as contesting, you should not rely on Spothole's data to fill in any gaps in your log.</p>
<h2 id="privacy" class="mt-4">Privacy</h2>
<p>Spothole collects no data about you, and there is no way to enter personally identifying information into the site apart from by spotting and alerting through Spothole or the various services it connects to. All spots and alerts are "timed out" and deleted from the system after a set interval, which by default is one hour for spots and one week for alerts.</p>
<p>Spothole collects no data about you on a permanent basis. All spots and alerts are "timed out" and deleted from the system after a set interval, which by default is one hour for spots and one week for alerts.</p>
<p>Settings you select from Spothole's menus are sent to the server, in order to provide the data with the requested filters. They are also stored in your browser's local storage, so that your preferences are remembered between sessions.</p>
<p>There are no trackers, no ads, and no cookies.</p>
<p>The data you provide can optionally include your login credentials for QRZ.com and HamQTH. You can provide these in the "Data" menu of most pages. If you do, Spothole will augment the data it produces with lookups from these services, which can for example provide more accurate markers on the map tab, and operator names when you mouse over a DX callsign. Spothole will still work fine if you don't provide these. The values you enter are sent to Spothole via HTTPS so are protected in transit, though of course you do have to trust Spothole with this sensitive data in order to use this feature.</p>
<p>Spothole uses no trackers, no ads, and no cookies.</p>
{% if len(web_ui_options["support-button-html"]) > 0 %}
<p><strong>Caveat: </strong> The owner of this server has chosen to inject their own content into the "spots" page. This is designed for a "donate" or "support this server" button. The functionality of this injected content is the responsibility of the server owner, rather than the Spothole software.</p>
{% end %}
@@ -67,7 +69,7 @@
<p>This software is dedicated to the memory of Tom G1PJB, SK, a friend and colleague who sadly passed away around the time I started writing it in Autumn 2025. I was looking forward to showing it to you when it was done.</p>
</div>
<script src="/js/common.js?v=1775203458"></script>
<script src="/js/common.js?v=1778343015"></script>
<script>$(document).ready(function() { $("#nav-link-about").addClass("active"); }); <!-- highlight active page in nav --></script>
{% end %}
+2 -2
View File
@@ -69,8 +69,8 @@
</div>
<script src="/js/common.js?v=1775203458"></script>
<script src="/js/add-spot.js?v=1775203458"></script>
<script src="/js/common.js?v=1778343015"></script>
<script src="/js/add-spot.js?v=1778343015"></script>
<script>$(document).ready(function() { $("#nav-link-add-spot").addClass("active"); }); <!-- highlight active page in nav --></script>
{% end %}
+18 -4
View File
@@ -8,7 +8,7 @@
</div>
<div class="col-auto">
<div class="d-inline-flex gap-1">
{% module Template("widgets/filters-display-buttons.html", web_ui_options=web_ui_options) %}
{% module Template("widgets/filters-display-data-buttons.html", web_ui_options=web_ui_options) %}
</div>
</div>
</div>
@@ -33,7 +33,7 @@
<div id="display-area" class="appearing-panel card mb-3">
{% module Template("widgets/display-area-header.html", web_ui_options=web_ui_options) %}
<div class="card-body">
<div id="display-container" class="row row-cols-1 row-cols-md-3 g-4">
<div id="display-container" class="row row-cols-1 row-cols-md-4 g-4">
<div class="col">
{% module Template("cards/time-zone.html", web_ui_options=web_ui_options) %}
</div>
@@ -50,14 +50,28 @@
</div>
</div>
<div id="data-area" class="appearing-panel card mb-3">
{% module Template("widgets/data-area-header.html", web_ui_options=web_ui_options) %}
<div class="card-body">
<div class="row row-cols-1 row-cols-md-4 g-4">
<div class="col">
{% module Template("cards/qrz.html", web_ui_options=web_ui_options) %}
</div>
<div class="col">
{% module Template("cards/hamqth.html", web_ui_options=web_ui_options) %}
</div>
</div>
</div>
</div>
<div id="table-container">
<table id="table" class="table"><thead><tr></tr></thead><tbody></tbody></table>
</div>
</div>
<script src="/js/common.js?v=1775203458"></script>
<script src="/js/alerts.js?v=1775203458"></script>
<script src="/js/common.js?v=1778343015"></script>
<script src="/js/alerts.js?v=1778343015"></script>
<script>$(document).ready(function() { $("#nav-link-alerts").addClass("active"); }); <!-- highlight active page in nav --></script>
{% end %}
+18 -4
View File
@@ -8,7 +8,7 @@
</div>
<div class="col-auto">
<div class="d-inline-flex gap-1">
{% module Template("widgets/filters-display-buttons.html", web_ui_options=web_ui_options) %}
{% module Template("widgets/filters-display-data-buttons.html", web_ui_options=web_ui_options) %}
</div>
</div>
</div>
@@ -55,6 +55,20 @@
</div>
</div>
<div id="data-area" class="appearing-panel card mb-3">
{% module Template("widgets/data-area-header.html", web_ui_options=web_ui_options) %}
<div class="card-body">
<div class="row row-cols-1 row-cols-md-4 g-4">
<div class="col">
{% module Template("cards/qrz.html", web_ui_options=web_ui_options) %}
</div>
<div class="col">
{% module Template("cards/hamqth.html", web_ui_options=web_ui_options) %}
</div>
</div>
</div>
</div>
<div id="bands-container"></div>
</div>
@@ -62,9 +76,9 @@
<script>
let spotProvidersEnabledByDefault = {% raw json_encode(web_ui_options["spot-providers-enabled-by-default"]) %};
</script>
<script src="/js/common.js?v=1775203458"></script>
<script src="/js/spotsbandsandmap.js?v=1775203458"></script>
<script src="/js/bands.js?v=1775203458"></script>
<script src="/js/common.js?v=1778343015"></script>
<script src="/js/spotsbandsandmap.js?v=1778343015"></script>
<script src="/js/bands.js?v=1778343015"></script>
<script>$(document).ready(function() { $("#nav-link-bands").addClass("active"); }); <!-- highlight active page in nav --></script>
{% end %}
+6 -5
View File
@@ -24,7 +24,7 @@
<title>Spothole</title>
<link rel="stylesheet" href="/css/style.css" type="text/css">
<link rel="stylesheet" href="/css/style.css?v=1778343015" type="text/css">
<link href="https://cdn.jsdelivr.net/npm/bootstrap@5.3.8/dist/css/bootstrap.min.css" rel="stylesheet"
integrity="sha384-sRIl4kxILFvY47J16cr9ZwB07vP4J8+LH7qKQnuqkuIAvNWLzeN8tE5YBujZqJLB" crossorigin="anonymous">
<link href="/fa/css/fontawesome.min.css" rel="stylesheet" />
@@ -46,10 +46,9 @@
crossorigin="anonymous"></script>
<script src="https://cdn.jsdelivr.net/npm/tinycolor2@1.6.0/cjs/tinycolor.min.js"></script>
<script src="https://misc.ianrenton.com/jsutils/utils.js?v=1775203458"></script>
<script src="https://misc.ianrenton.com/jsutils/storage.js?v=1775203458"></script>
<script src="https://misc.ianrenton.com/jsutils/ui-ham.js?v=1775203458"></script>
<script src="https://misc.ianrenton.com/jsutils/geo.js?v=1775203458"></script>
<script src="https://misc.ianrenton.com/jsutils/utils.js?v=1778343015"></script>
<script src="https://misc.ianrenton.com/jsutils/ui-ham.js?v=1778343015"></script>
<script src="https://misc.ianrenton.com/jsutils/geo.js?v=1778343015"></script>
</head>
<body>
@@ -71,7 +70,9 @@
{% if allow_spotting %}
<li class="nav-item ms-4"><a href="/add-spot" class="nav-link" id="nav-link-add-spot"><i class="fa-solid fa-comment"></i> Add&nbsp;Spot</a></li>
{% end %}
{% if has_hamqsl or has_noaa_forecast %}
<li class="nav-item ms-4"><a href="/conditions" class="nav-link" id="nav-link-conditions"><i class="fa-solid fa-sun"></i> Conditions</a></li>
{% end %}
<li class="nav-item ms-4"><a href="/status" class="nav-link" id="nav-link-status"><i class="fa-solid fa-chart-simple"></i> Status</a></li>
<li class="nav-item ms-4"><a href="/about" class="nav-link" id="nav-link-about"><i class="fa-solid fa-circle-info"></i> About</a></li>
<li class="nav-item ms-4"><a href="/apidocs" class="nav-link" id="nav-link-api"><i class="fa-solid fa-gear"></i> API</a></li>
+11
View File
@@ -0,0 +1,11 @@
<div class="card">
<div class="card-body">
<h5 class="card-title mb-3">Audio</h5>
<div class="form-group">
<div class="form-check form-check-inline">
<input class="form-check-input storeable-checkbox" type="checkbox" id="pingOnNewSpots" value="pingOnNewSpots" oninput="saveSettings();">
<label class="form-check-label" for="pingOnNewSpots">Ping on new spots</label>
</div>
</div>
</div>
</div>
+1 -1
View File
@@ -1,6 +1,6 @@
<div class="card">
<div class="card-body">
<h5 class="card-title">Bands</h5>
<p id="band-options" class="card-text spothole-card-text"></p>
<div id="band-options" class="card-text spothole-card-text"></div>
</div>
</div>
+27
View File
@@ -0,0 +1,27 @@
<div class="card">
<div class="card-body">
<h5 class="card-title">Map Style</h5>
<p class="card-text spothole-card-text">
<label for="basemap" class="form-label">Basemap</label>
<select id="basemap" class="storeable-select form-select" oninput="displayUpdated();">
<option value="OpenStreetMap.Mapnik" selected>OpenStreetMap Mapnik</option>
<option value="OpenStreetMap.Mapnik.Dark">OpenStreetMap Mapnik (Dark)</option>
<option value="Esri.NatGeoWorldMap">ESRI NatGeo World Map</option>
<option value="Esri.WorldTopoMap">ESRI World Topo Map</option>
<option value="Esri.WorldShadedRelief">ESRI World Shaded Relief</option>
<option value="Esri.WorldImagery">ESRI World Imagery</option>
<option value="CartoDB.Voyager">CartoDB Voyager</option>
<option value="CartoDB.DarkMatter">CartoDB DarkMatter</option>
</select>
</p>
<p class="card-text spothole-card-text">
<label for="basemapOpacity" class="form-label">Opacity</label>
<select id="basemapOpacity" class="storeable-select form-select" oninput="displayUpdated();">
<option value="1">100%</option>
<option value="0.75">75%</option>
<option value="0.5">50%</option>
<option value="0.25">25%</option>
</select>
</p>
</div>
</div>
+1 -1
View File
@@ -1,6 +1,6 @@
<div class="card">
<div class="card-body">
<h5 class="card-title">DE Continent</h5>
<p id="de-continent-options" class="card-text spothole-card-text"></p>
<div id="de-continent-options" class="card-text spothole-card-text"></div>
</div>
</div>
+1 -1
View File
@@ -2,7 +2,7 @@
<div class="card-body">
<h5 class="card-title">Duration Limit <i class='fa-solid fa-circle-question' title='Some users create long-duration alerts for the period they will be generally in and around xOTA references, when they are not indending to be on the air most of the time. Use this control to restrict the maximum duration of spots that the software will display, and exclude any with a long duration, to avoid these filling up the list. By default, we allow DXpeditions to be displayed even if they are longer than this limit, because on a DXpedition the operators typically ARE on the air most of the time.'></i></h5>
<p class="card-text spothole-card-text">
Hide any alerts lasting more than:<br/>
<label for="max-duration" class="form-label">Hide any alerts lasting more than</label>
<select id="max-duration" class="storeable-select form-select" onclick="filtersUpdated();" style="width: 8em; display: inline-block;">
<option value="10800">3 hours</option>
<option value="43200">12 hours</option>
+1 -1
View File
@@ -1,6 +1,6 @@
<div class="card">
<div class="card-body">
<h5 class="card-title">DX Continent</h5>
<p id="dx-continent-options" class="card-text spothole-card-text"></p>
<div id="dx-continent-options" class="card-text spothole-card-text"></div>
</div>
</div>
+27
View File
@@ -0,0 +1,27 @@
<div class="card">
<div class="card-body">
<h5 class="card-title">HamQTH</h5>
<div class="card-text spothole-card-text">
<div class="form-check mb-2">
<input type="checkbox" class="storeable-checkbox form-check-input" id="hamqth-enabled" onchange="saveSettings();">
<label for="hamqth-enabled" class="form-check-label">Use data from HamQTH</label>
</div>
<div class="mb-2">
<input type="text" class="storeable-text form-control form-control-sm" id="hamqth-username" placeholder="Username (Callsign)" onchange="saveSettings();" autocomplete="username">
</div>
<div class="mb-2">
<input type="password" class="password-field form-control form-control-sm" id="hamqth-password" placeholder="Password" data-remember-checkbox="hamqth-remember-password" onchange="saveSettings();" autocomplete="current-password">
</div>
<div class="form-check">
<input type="checkbox" class="storeable-checkbox form-check-input" id="hamqth-remember-password" onchange="saveSettings();">
<label for="hamqth-remember-password" class="form-check-label">Remember password</label>
</div>
<div class="mt-3">
<button type="button" class="btn btn-outline-secondary btn-sm" onclick="location.reload();">Reload with this data</button>
</div>
<div class="mt-1">
<small>See <a href="/about#privacy">Privacy</a> for more information.</small>
</div>
</div>
</div>
</div>
+31 -1
View File
@@ -1,11 +1,41 @@
<div class="card">
<div class="card-body">
<h5 class="card-title">Map Features</h5>
<h5 class="card-title mb-3">Map Features</h5>
<div class="form-group">
<div class="form-check form-check-inline">
<input class="form-check-input storeable-checkbox" type="checkbox" id="mapShowGeodesics" value="mapShowGeodesics" oninput="displayUpdated();">
<label class="form-check-label" for="mapShowGeodesics">Geodesic Lines</label>
</div>
</div>
<div class="form-group">
<div class="form-check form-check-inline">
<input class="form-check-input storeable-checkbox" type="checkbox" id="showTerminator" oninput="displayUpdated();" checked>
<label class="form-check-label" for="showTerminator">Terminator / Greyline</label>
</div>
</div>
<div class="form-group">
<div class="form-check form-check-inline">
<input class="form-check-input storeable-checkbox" type="checkbox" id="showMaidenheadGrid" oninput="displayUpdated();">
<label class="form-check-label" for="showMaidenheadGrid">Maidenhead Grid</label>
</div>
</div>
<div class="form-group">
<div class="form-check form-check-inline">
<input class="form-check-input storeable-checkbox" type="checkbox" id="showCQZones" oninput="displayUpdated();">
<label class="form-check-label" for="showCQZones">CQ Zones</label>
</div>
</div>
<div class="form-group">
<div class="form-check form-check-inline">
<input class="form-check-input storeable-checkbox" type="checkbox" id="showITUZones" oninput="displayUpdated();">
<label class="form-check-label" for="showITUZones">ITU Zones</label>
</div>
</div>
<div class="form-group">
<div class="form-check form-check-inline">
<input class="form-check-input storeable-checkbox" type="checkbox" id="showWABWAIGrid" oninput="displayUpdated();">
<label class="form-check-label" for="showWABWAIGrid">WAB/WAI Grid</label>
</div>
</div>
</div>
</div>
+1 -1
View File
@@ -1,6 +1,6 @@
<div class="card">
<div class="card-body">
<h5 class="card-title">Modes</h5>
<p id="mode-options" class="card-text spothole-card-text"></p>
<div id="mode-options" class="card-text spothole-card-text"></div>
</div>
</div>
+1 -1
View File
@@ -2,7 +2,7 @@
<div class="card-body">
<h5 class="card-title">Number of Alerts</h5>
<p class="card-text spothole-card-text">Show up to
<select id="alerts-to-fetch" class="storeable-select form-select ms-2" oninput="filtersUpdated();" style="width: 5em;display: inline-block;">
<select id="alerts-to-fetch" class="storeable-select form-select ms-2 me-2" oninput="filtersUpdated();" style="width: 5em;display: inline-block;">
{% for c in web_ui_options["alert-count"] %}
<option value="{{c}}" {% if web_ui_options["alert-count-default"] == c %}selected{% end %}>{{c}}</option>
{% end %}
+27
View File
@@ -0,0 +1,27 @@
<div class="card">
<div class="card-body">
<h5 class="card-title">QRZ.com</h5>
<div class="card-text spothole-card-text">
<div class="form-check mb-2">
<input type="checkbox" class="storeable-checkbox form-check-input" id="qrz-enabled" onchange="saveSettings();">
<label for="qrz-enabled" class="form-check-label">Use data from QRZ.com</label>
</div>
<div class="mb-2">
<input type="text" class="storeable-text form-control form-control-sm" id="qrz-username" placeholder="Username (Callsign)" onchange="saveSettings();" autocomplete="username">
</div>
<div class="mb-2">
<input type="password" class="password-field form-control form-control-sm" id="qrz-password" placeholder="Password" data-remember-checkbox="qrz-remember-password" onchange="saveSettings();" autocomplete="current-password">
</div>
<div class="form-check">
<input type="checkbox" class="storeable-checkbox form-check-input" id="qrz-remember-password" onchange="saveSettings();">
<label for="qrz-remember-password" class="form-check-label">Remember password</label>
</div>
<div class="mt-3">
<button type="button" class="btn btn-outline-secondary btn-sm" onclick="location.reload();">Reload with this data</button>
</div>
<div class="mt-1">
<small>See <a href="/about#privacy">Privacy</a> for more information.</small>
</div>
</div>
</div>
</div>
+1 -1
View File
@@ -1,6 +1,6 @@
<div class="card">
<div class="card-body">
<h5 class="card-title">SIGs</h5>
<p id="sig-options" class="card-text spothole-card-text"></p>
<div id="sig-options" class="card-text spothole-card-text"></div>
</div>
</div>
+1 -1
View File
@@ -1,6 +1,6 @@
<div class="card">
<div class="card-body">
<h5 class="card-title">Sources</h5>
<p id="source-options" class="card-text spothole-card-text"></p>
<div id="source-options" class="card-text spothole-card-text"></div>
</div>
</div>
+29 -29
View File
@@ -1,35 +1,35 @@
<div class="card">
<div class="card-body">
<h5 class="card-title">Table Columns</h5>
<div class="form-group">
<div class="form-check form-check-inline">
<input class="form-check-input storeable-checkbox" type="checkbox" id="tableShowStartTime" value="tableShowStartTime" oninput="columnsUpdated();" checked>
<label class="form-check-label" for="tableShowStartTime">Start Time</label>
</div>
<div class="form-check form-check-inline">
<input class="form-check-input storeable-checkbox" type="checkbox" id="tableShowEndTime" value="tableShowEndTime" oninput="columnsUpdated();" checked>
<label class="form-check-label" for="tableShowEndTime">End Time</label>
</div>
<div class="form-check form-check-inline">
<input class="form-check-input storeable-checkbox" type="checkbox" id="tableShowDX" value="tableShowDX" oninput="columnsUpdated();" checked>
<label class="form-check-label" for="tableShowDX">DX</label>
</div>
<div class="form-check form-check-inline">
<input class="form-check-input storeable-checkbox" type="checkbox" id="tableShowFreqsModes" value="tableShowFreqsModes" oninput="columnsUpdated();" checked>
<label class="form-check-label" for="tableShowFreqsModes">Frequencies & Modes</label>
</div>
<div class="form-check form-check-inline">
<input class="form-check-input storeable-checkbox" type="checkbox" id="tableShowComment" value="tableShowComment" oninput="columnsUpdated();" checked>
<label class="form-check-label" for="tableShowComment">Comment</label>
</div>
<div class="form-check form-check-inline">
<input class="form-check-input storeable-checkbox" type="checkbox" id="tableShowSource" value="tableShowSource" oninput="columnsUpdated();" checked>
<label class="form-check-label" for="tableShowSource">Source</label>
</div>
<div class="form-check form-check-inline">
<input class="form-check-input storeable-checkbox" type="checkbox" id="tableShowRef" value="tableShowRef" oninput="columnsUpdated();" checked>
<label class="form-check-label" for="tableShowRef">Ref.</label>
</div>
<div class="row row-cols-2 g-1">
<div class="col"><div class="form-check">
<input class="form-check-input storeable-checkbox" type="checkbox" id="tableShowStartTime" value="tableShowStartTime" oninput="columnsUpdated();" checked>
<label class="form-check-label" for="tableShowStartTime">Start Time</label>
</div></div>
<div class="col"><div class="form-check">
<input class="form-check-input storeable-checkbox" type="checkbox" id="tableShowEndTime" value="tableShowEndTime" oninput="columnsUpdated();" checked>
<label class="form-check-label" for="tableShowEndTime">End Time</label>
</div></div>
<div class="col"><div class="form-check">
<input class="form-check-input storeable-checkbox" type="checkbox" id="tableShowDX" value="tableShowDX" oninput="columnsUpdated();" checked>
<label class="form-check-label" for="tableShowDX">DX</label>
</div></div>
<div class="col"><div class="form-check">
<input class="form-check-input storeable-checkbox" type="checkbox" id="tableShowFreqsModes" value="tableShowFreqsModes" oninput="columnsUpdated();" checked>
<label class="form-check-label" for="tableShowFreqsModes">Freq &amp; Mode</label>
</div></div>
<div class="col"><div class="form-check">
<input class="form-check-input storeable-checkbox" type="checkbox" id="tableShowComment" value="tableShowComment" oninput="columnsUpdated();" checked>
<label class="form-check-label" for="tableShowComment">Comment</label>
</div></div>
<div class="col"><div class="form-check">
<input class="form-check-input storeable-checkbox" type="checkbox" id="tableShowSource" value="tableShowSource" oninput="columnsUpdated();" checked>
<label class="form-check-label" for="tableShowSource">Source</label>
</div></div>
<div class="col"><div class="form-check">
<input class="form-check-input storeable-checkbox" type="checkbox" id="tableShowRef" value="tableShowRef" oninput="columnsUpdated();" checked>
<label class="form-check-label" for="tableShowRef">Ref.</label>
</div></div>
</div>
</div>
</div>
+41 -41
View File
@@ -1,47 +1,47 @@
<div class="card">
<div class="card-body">
<h5 class="card-title">Table Columns</h5>
<div class="form-group">
<div class="form-check form-check-inline">
<input class="form-check-input storeable-checkbox" type="checkbox" id="tableShowTime" value="tableShowTime" oninput="columnsUpdated();" checked>
<label class="form-check-label" for="tableShowTime">Time</label>
</div>
<div class="form-check form-check-inline">
<input class="form-check-input storeable-checkbox" type="checkbox" id="tableShowDX" value="tableShowDX" oninput="columnsUpdated();" checked>
<label class="form-check-label" for="tableShowDX">DX</label>
</div>
<div class="form-check form-check-inline">
<input class="form-check-input storeable-checkbox" type="checkbox" id="tableShowFreq" value="tableShowFreq" oninput="columnsUpdated();" checked>
<label class="form-check-label" for="tableShowFreq">Frequency</label>
</div>
<div class="form-check form-check-inline">
<input class="form-check-input storeable-checkbox" type="checkbox" id="tableShowMode" value="tableShowMode" oninput="columnsUpdated();" checked>
<label class="form-check-label" for="tableShowMode">Mode</label>
</div>
<div class="form-check form-check-inline">
<input class="form-check-input storeable-checkbox" type="checkbox" id="tableShowComment" value="tableShowComment" oninput="columnsUpdated();" checked>
<label class="form-check-label" for="tableShowComment">Comment</label>
</div>
<div class="form-check form-check-inline">
<input class="form-check-input storeable-checkbox" type="checkbox" id="tableShowBearing" value="tableShowBearing" oninput="columnsUpdated();">
<label class="form-check-label" for="tableShowBearing">Bearing</label>
</div>
<div class="form-check form-check-inline">
<input class="form-check-input storeable-checkbox" type="checkbox" id="tableShowType" value="tableShowType" oninput="columnsUpdated();" checked>
<label class="form-check-label" for="tableShowType">Type</label>
</div>
<div class="form-check form-check-inline">
<input class="form-check-input storeable-checkbox" type="checkbox" id="tableShowRef" value="tableShowRef" oninput="columnsUpdated();" checked>
<label class="form-check-label" for="tableShowRef">Ref.</label>
</div>
<div class="form-check form-check-inline">
<input class="form-check-input storeable-checkbox" type="checkbox" id="tableShowDE" value="tableShowDE" oninput="columnsUpdated();" checked>
<label class="form-check-label" for="tableShowDE">DE</label>
</div>
<div class="form-check form-check-inline">
<input class="form-check-input storeable-checkbox" type="checkbox" id="tableShowWorkedCheckbox" value="tableShowWorkedCheckbox" oninput="columnsUpdated();" checked>
<label class="form-check-label" for="tableShowWorkedCheckbox">Worked?</label>
</div>
<div class="row row-cols-2 g-1">
<div class="col"><div class="form-check">
<input class="form-check-input storeable-checkbox" type="checkbox" id="tableShowTime" value="tableShowTime" oninput="columnsUpdated();" checked>
<label class="form-check-label" for="tableShowTime">Time</label>
</div></div>
<div class="col"><div class="form-check">
<input class="form-check-input storeable-checkbox" type="checkbox" id="tableShowDX" value="tableShowDX" oninput="columnsUpdated();" checked>
<label class="form-check-label" for="tableShowDX">DX</label>
</div></div>
<div class="col"><div class="form-check">
<input class="form-check-input storeable-checkbox" type="checkbox" id="tableShowFreq" value="tableShowFreq" oninput="columnsUpdated();" checked>
<label class="form-check-label" for="tableShowFreq">Frequency</label>
</div></div>
<div class="col"><div class="form-check">
<input class="form-check-input storeable-checkbox" type="checkbox" id="tableShowMode" value="tableShowMode" oninput="columnsUpdated();" checked>
<label class="form-check-label" for="tableShowMode">Mode</label>
</div></div>
<div class="col"><div class="form-check">
<input class="form-check-input storeable-checkbox" type="checkbox" id="tableShowComment" value="tableShowComment" oninput="columnsUpdated();" checked>
<label class="form-check-label" for="tableShowComment">Comment</label>
</div></div>
<div class="col"><div class="form-check">
<input class="form-check-input storeable-checkbox" type="checkbox" id="tableShowBearing" value="tableShowBearing" oninput="columnsUpdated();">
<label class="form-check-label" for="tableShowBearing">Bearing</label>
</div></div>
<div class="col"><div class="form-check">
<input class="form-check-input storeable-checkbox" type="checkbox" id="tableShowType" value="tableShowType" oninput="columnsUpdated();" checked>
<label class="form-check-label" for="tableShowType">Type</label>
</div></div>
<div class="col"><div class="form-check">
<input class="form-check-input storeable-checkbox" type="checkbox" id="tableShowRef" value="tableShowRef" oninput="columnsUpdated();" checked>
<label class="form-check-label" for="tableShowRef">Ref.</label>
</div></div>
<div class="col"><div class="form-check">
<input class="form-check-input storeable-checkbox" type="checkbox" id="tableShowDE" value="tableShowDE" oninput="columnsUpdated();" checked>
<label class="form-check-label" for="tableShowDE">DE</label>
</div></div>
<div class="col"><div class="form-check">
<input class="form-check-input storeable-checkbox" type="checkbox" id="tableShowWorkedCheckbox" value="tableShowWorkedCheckbox" oninput="columnsUpdated();" checked>
<label class="form-check-label" for="tableShowWorkedCheckbox">Worked?</label>
</div></div>
</div>
</div>
</div>
+148 -105
View File
@@ -1,90 +1,83 @@
{% extends "base.html" %}
{% block content %}
{% if has_hamqsl %}
<div class="card mt-5">
<div class="card-header">
Propagation Conditions
</div>
<div class="card-body">
<div class="row row-cols-1 row-cols-md-2 g-3">
<div class="col">
<div class="card h-100">
<div class="card-body">
<h5 class="card-title">HF</h5>
<table class="table table-sm mt-2">
<thead>
<tr>
<th>Band</th>
<th>Day</th>
<th>Night</th>
</tr>
</thead>
<tbody>
<tr>
<td>80-40m</td>
<td id="hf-conditions-80m-40m-day"></td>
<td id="hf-conditions-80m-40m-night"></td>
</tr>
<tr>
<td>30-20m</td>
<td id="hf-conditions-30m-20m-day"></td>
<td id="hf-conditions-30m-20m-night"></td>
</tr>
<tr>
<td>17-15m</td>
<td id="hf-conditions-17m-15m-day"></td>
<td id="hf-conditions-17m-15m-night"></td>
</tr>
<tr>
<td>12-10m</td>
<td id="hf-conditions-12m-10m-day"></td>
<td id="hf-conditions-12m-10m-night"></td>
</tr>
</tbody>
</table>
</div>
</div>
<div class="col mt-3 px-3">
<h5>HF</h5>
<table class="table table-sm mt-2">
<thead>
<tr>
<th>Band</th>
<th>Day</th>
<th>Night</th>
</tr>
</thead>
<tbody>
<tr>
<td>80-40m</td>
<td id="hf-conditions-80m-40m-day"></td>
<td id="hf-conditions-80m-40m-night"></td>
</tr>
<tr>
<td>30-20m</td>
<td id="hf-conditions-30m-20m-day"></td>
<td id="hf-conditions-30m-20m-night"></td>
</tr>
<tr>
<td>17-15m</td>
<td id="hf-conditions-17m-15m-day"></td>
<td id="hf-conditions-17m-15m-night"></td>
</tr>
<tr>
<td>12-10m</td>
<td id="hf-conditions-12m-10m-day"></td>
<td id="hf-conditions-12m-10m-night"></td>
</tr>
</tbody>
</table>
</div>
<div class="col">
<div class="card h-100">
<div class="card-body">
<h5 class="card-title">VHF</h5>
<table class="table table-sm mt-2">
<thead>
<tr>
<th>Propagation Mode</th>
<th>Condition</th>
</tr>
</thead>
<tbody>
<tr>
<td>Sporadic-E 6m (Europe)</td>
<td id="vhf-conditions-es_6m_europe"></td>
</tr>
<tr>
<td>Sporadic-E 4m (Europe)</td>
<td id="vhf-conditions-es_4m_europe"></td>
</tr>
<tr>
<td>Sporadic-E 2m (Europe)</td>
<td id="vhf-conditions-es_2m_europe"></td>
</tr>
<tr>
<td>Sporadic-E 2m (North America)</td>
<td id="vhf-conditions-es_2m_na"></td>
</tr>
<tr>
<td>Aurora (Northern Hemisphere)</td>
<td id="vhf-conditions-vhf_aurora_northern_hemi"></td>
</tr>
<tr>
<td>Aurora Minimum Latitude</td>
<td id="vhf-conditions-aurora-lat"></td>
</tr>
</tbody>
</table>
</div>
</div>
<div class="col mt-3 px-3">
<h5>VHF</h5>
<table class="table table-sm mt-2">
<thead>
<tr>
<th>Propagation Mode</th>
<th>Condition</th>
</tr>
</thead>
<tbody>
<tr>
<td>Sporadic-E 6m (Europe)</td>
<td id="vhf-conditions-es_6m_europe"></td>
</tr>
<tr>
<td>Sporadic-E 4m (Europe)</td>
<td id="vhf-conditions-es_4m_europe"></td>
</tr>
<tr>
<td>Sporadic-E 2m (Europe)</td>
<td id="vhf-conditions-es_2m_europe"></td>
</tr>
<tr>
<td>Sporadic-E 2m (North America)</td>
<td id="vhf-conditions-es_2m_na"></td>
</tr>
<tr>
<td>Aurora (Northern Hemisphere)</td>
<td id="vhf-conditions-vhf_aurora_northern_hemi"></td>
</tr>
<tr>
<td>Aurora Minimum Latitude</td>
<td id="vhf-conditions-aurora-lat"></td>
</tr>
</tbody>
</table>
</div>
</div>
<div class="form-text mt-3">Data from <a href="https://hamqsl.com">HamQSL.com</a>.</div>
@@ -95,7 +88,7 @@
<div class="card-header">
Solar Weather
</div>
<div class="card-body">
<div class="card-body px-3">
<div class="row border-bottom align-items-start me-0">
<div class="col-12 col-md-2 py-2 fw-bold">Solar Flux</div>
<div id="sw-solar-flux-vals" class="col-12 col-md-3 py-2">
@@ -118,7 +111,9 @@
</div>
<div class="row border-bottom align-items-start me-0">
<div class="col-12 col-md-2 py-2 fw-bold">X-ray Flux</div>
<div id="sw-xray-vals" class="col-12 col-md-3 py-2"><strong id="sw-x-ray"></strong></div>
<div id="sw-xray-vals" class="col-12 col-md-3 py-2">
<span class="me-3"><strong id="sw-xray"></strong></span>
<span class="me-3"><strong>R</strong><strong id="sw-radio-blackout-scale"></strong></span></div>
<div id="sw-xray-desc" class="col-12 col-md-7 py-2"></div>
</div>
<div class="row border-bottom align-items-start me-0">
@@ -137,6 +132,46 @@
<div class="form-text mt-3">Data from <a href="https://hamqsl.com">HamQSL.com</a>.</div>
</div>
</div>
{% end %}
{% if has_noaa_forecast %}
<div class="card mt-5">
<div class="card-header">
Forecast
</div>
<div class="card-body">
<div class="row mb-4">
<div class="col px-3">
<h5>K-index Forecast</h5>
<canvas id="forecast-kp-chart" class="mt-3 mb-3"></canvas>
</div>
</div>
<div class="row row-cols-1 row-cols-md-2 g-3">
<div class="col mt-3 px-3">
<h5>Solar Storm Forecast</h5>
<table id="forecast-solar-storm-table" class="table table-sm mt-2">
<thead>
<tr id="forecast-solar-storm-head"></tr>
</thead>
<tbody id="forecast-solar-storm-tbody"></tbody>
</table>
</div>
<div class="col mt-3 px-3">
<h5>Radio Blackout Forecast</h5>
<table id="forecast-blackout-table" class="table table-sm mt-2">
<thead>
<tr id="forecast-blackout-head"></tr>
</thead>
<tbody id="forecast-blackout-tbody"></tbody>
</table>
</div>
</div>
<div class="form-text mt-3">Data from <a href="https://www.swpc.noaa.gov/">NOAA Space Weather Prediction
Center</a>.
</div>
</div>
</div>
{% end %}
<div class="card mt-5">
<div class="card-header">
@@ -145,7 +180,8 @@
<div class="card-body">
<div class="mb-3">
<label for="dxstats-de-continent" class="form-label">Your continent:</label>
<select id="dxstats-de-continent" class="form-select storeable-select d-inline-block ms-2" style="width: auto;" oninput="dxStatsContientChanged();">
<select id="dxstats-de-continent" class="form-select storeable-select d-inline-block ms-2"
style="width: auto;" oninput="dxStatsContientChanged();">
<option value="EU">Europe</option>
<option value="NA">North America</option>
<option value="SA">South America</option>
@@ -158,39 +194,46 @@
<div class="table-responsive">
<table class="table table-sm table-bordered mb-0">
<thead>
<tr>
<th></th>
<th>160m</th>
<th>80m</th>
<th>60m</th>
<th>40m</th>
<th>30m</th>
<th>20m</th>
<th>17m</th>
<th>15m</th>
<th>12m</th>
<th>10m</th>
<th>6m</th>
</tr>
<tr>
<th></th>
<th>160m</th>
<th>80m</th>
<th>60m</th>
<th>40m</th>
<th>30m</th>
<th>20m</th>
<th>17m</th>
<th>15m</th>
<th>12m</th>
<th>10m</th>
<th>6m</th>
</tr>
</thead>
<tbody>
{% for continent in ["EU", "NA", "SA", "AS", "AF", "OC", "AN"] %}
<tr>
<td class="fw-bold">{{ continent }}</td>
{% for band in ["160m", "80m", "60m", "40m", "30m", "20m", "17m", "15m", "12m", "10m", "6m"] %}
<td id="dxstats-{{ continent }}-{{ band }}"></td>
{% end %}
</tr>
{% for continent in ["EU", "NA", "SA", "AS", "AF", "OC", "AN"] %}
<tr>
<td class="fw-bold">{{ continent }}</td>
{% for band in ["160m", "80m", "60m", "40m", "30m", "20m", "17m", "15m", "12m", "10m", "6m"] %}
<td id="dxstats-{{ continent }}-{{ band }}"></td>
{% end %}
</tr>
{% end %}
</tbody>
</table>
</div>
<div class="form-text mt-2">This table shows the number of spots in the past hour received in your continent, where the DX continent and band are as shown in the table. Bands with high numbers of spots are likely to be the best ones for making contact with the continent you want right now. Bear in mind that some bands and some continents are inherently much rarer than others.</div>
<div class="form-text mt-2">This table shows the number of spots in the past hour received in your continent,
where the DX continent and band are as shown in the table. Bands with high numbers of spots are likely to be
the best ones for making contact with the continent you want right now. Bear in mind that some bands and
some continents are inherently much rarer than others.
</div>
</div>
</div>
<script src="/js/common.js?v=1775203458"></script>
<script src="/js/conditions.js?v=1775203458"></script>
<script>$(document).ready(function() { $("#nav-link-conditions").addClass("active"); }); <!-- highlight active page in nav --></script>
<script src="https://cdn.jsdelivr.net/npm/chart.js@4.4.9/dist/chart.umd.min.js"></script>
<script src="/js/common.js?v=1778343015"></script>
<script src="/js/conditions.js?v=1778343015"></script>
<script>$(document).ready(function () {
$("#nav-link-conditions").addClass("active");
}); <!-- highlight active page in nav --></script>
{% end %}
+27 -4
View File
@@ -7,7 +7,7 @@
<div class="col-auto me-auto pt-3"></div>
<div class="col-auto">
<div class="d-inline-flex gap-1">
{% module Template("widgets/filters-display-buttons.html", web_ui_options=web_ui_options) %}
{% module Template("widgets/filters-display-data-buttons.html", web_ui_options=web_ui_options) %}
</div>
</div>
</div>
@@ -47,6 +47,9 @@
<div class="col">
{% module Template("cards/spot-age.html", web_ui_options=web_ui_options) %}
</div>
<div class="col">
{% module Template("cards/basemap.html", web_ui_options=web_ui_options) %}
</div>
<div class="col">
{% module Template("cards/map-features.html", web_ui_options=web_ui_options) %}
</div>
@@ -56,6 +59,20 @@
</div>
</div>
</div>
<div id="data-area" class="appearing-panel card mb-3">
{% module Template("widgets/data-area-header.html", web_ui_options=web_ui_options) %}
<div class="card-body">
<div class="row row-cols-1 row-cols-md-4 g-4">
<div class="col">
{% module Template("cards/qrz.html", web_ui_options=web_ui_options) %}
</div>
<div class="col">
{% module Template("cards/hamqth.html", web_ui_options=web_ui_options) %}
</div>
</div>
</div>
</div>
</div>
</div>
@@ -65,14 +82,20 @@
<script src="https://cdn.jsdelivr.net/npm/leaflet-providers@2.0.0/leaflet-providers.min.js"></script>
<script src="https://cdn.jsdelivr.net/npm/leaflet-extra-markers@1.2.2/src/assets/js/leaflet.extra-markers.min.js" type="module"></script>
<script src="https://cdn.jsdelivr.net/npm/leaflet.geodesic"></script>
<script src="https://unpkg.com/leaflet.vectorgrid@latest/dist/Leaflet.VectorGrid.js"></script>
<script src="https://cdn.jsdelivr.net/npm/text-image/dist/text-image.js"></script>
<script src="https://cdn.jsdelivr.net/npm/@joergdietrich/leaflet.terminator@1.1.0/L.Terminator.min.js"></script>
<script src="https://ianrenton.github.io/Leaflet.Maidenhead/src/L.Maidenhead.js"></script>
<script src="https://ha8tks.github.io/Leaflet.ITUzones/src/L.ITUzones.js"></script>
<script src="https://ha8tks.github.io/Leaflet.CQzones/src/L.CQzones.js"></script>
<script src="https://misc.ianrenton.com/Leaflet.WorkedAllBritainIreland/L.WorkedAllBritainIreland.js"></script>
<script>
let spotProvidersEnabledByDefault = {% raw json_encode(web_ui_options["spot-providers-enabled-by-default"]) %};
</script>
<script src="/js/common.js?v=1775203458"></script>
<script src="/js/spotsbandsandmap.js?v=1775203458"></script>
<script src="/js/map.js?v=1775203458"></script>
<script src="/js/common.js?v=1778343015"></script>
<script src="/js/spotsbandsandmap.js?v=1778343015"></script>
<script src="/js/map.js?v=1778343015"></script>
<script>$(document).ready(function() { $("#nav-link-map").addClass("active"); }); <!-- highlight active page in nav --></script>
{% end %}
+27 -10
View File
@@ -19,7 +19,7 @@
<div class="col-md-8 text-end">
<div class="d-inline-flex gap-3">
{% module Template("widgets/search.html", web_ui_options=web_ui_options) %}
{% module Template("widgets/filters-display-buttons.html", web_ui_options=web_ui_options) %}
{% module Template("widgets/filters-display-data-buttons.html", web_ui_options=web_ui_options) %}
</div>
</div>
</div>
@@ -62,18 +62,35 @@
<div class="col">
{% module Template("cards/number-of-spots.html", web_ui_options=web_ui_options) %}
</div>
<div class="col">
{% module Template("cards/location.html", web_ui_options=web_ui_options) %}
</div>
<div class="col">
{% module Template("cards/worked-calls.html", web_ui_options=web_ui_options) %}
</div>
<div class="col">
{% module Template("cards/color-scheme-and-band-color-scheme.html", web_ui_options=web_ui_options) %}
</div>
<div class="col">
{% module Template("cards/table-columns-spots.html", web_ui_options=web_ui_options) %}
</div>
<div class="col">
{% module Template("cards/audio.html", web_ui_options=web_ui_options) %}
</div>
</div>
</div>
</div>
<div id="data-area" class="appearing-panel card mb-3">
{% module Template("widgets/data-area-header.html", web_ui_options=web_ui_options) %}
<div class="card-body">
<div class="row row-cols-1 row-cols-md-4 g-4">
<div class="col">
{% module Template("cards/qrz.html", web_ui_options=web_ui_options) %}
</div>
<div class="col">
{% module Template("cards/hamqth.html", web_ui_options=web_ui_options) %}
</div>
<div class="col">
{% module Template("cards/location.html", web_ui_options=web_ui_options) %}
</div>
<div class="col">
{% module Template("cards/worked-calls.html", web_ui_options=web_ui_options) %}
</div>
</div>
</div>
</div>
@@ -87,9 +104,9 @@
<script>
let spotProvidersEnabledByDefault = {% raw json_encode(web_ui_options["spot-providers-enabled-by-default"]) %};
</script>
<script src="/js/common.js?v=1775203458"></script>
<script src="/js/spotsbandsandmap.js?v=1775203458"></script>
<script src="/js/spots.js?v=1775203458"></script>
<script src="/js/common.js?v=1778343015"></script>
<script src="/js/spotsbandsandmap.js?v=1778343015"></script>
<script src="/js/spots.js?v=1778343015"></script>
<script>$(document).ready(function() { $("#nav-link-spots").addClass("active"); }); <!-- highlight active page in nav --></script>
{% end %}
+2 -2
View File
@@ -59,8 +59,8 @@
</div>
</div>
<script src="/js/common.js?v=1775203458"></script>
<script src="/js/status.js?v=1775203458"></script>
<script src="/js/common.js?v=1778343015"></script>
<script src="/js/status.js?v=1778343015"></script>
<script>
$(document).ready(function() { $("#nav-link-status").addClass("active"); }); <!-- highlight active page in nav -->
</script>
+1 -1
View File
@@ -1,4 +1,4 @@
<label class="form-check-label" for="band-color-scheme">Band color scheme</label><br/>
<label class="form-check-label form-label" for="band-color-scheme">Band color scheme</label><br/>
<select id="band-color-scheme" class="storeable-select form-select d-inline-block" oninput="setBandColorSchemeFromUI();" style="display: inline-block;">
<option value="PSK Reporter" {% if web_ui_options["band-color-scheme-default"] == "PSK Reporter" %}selected{% end %}>PSK Reporter</option>
<option value="PSK Reporter (Adjusted)" {% if web_ui_options["band-color-scheme-default"] == "PSK Reporter (Adjusted)" %}selected{% end %}>PSK Reporter (Adjusted)</option>
+1 -1
View File
@@ -1,4 +1,4 @@
<label class="form-check-label" for="color-scheme">UI color scheme</label>
<label class="form-check-label form-label" for="color-scheme">UI color scheme</label>
<select id="color-scheme" class="storeable-select form-select d-inline-block" oninput="setColorSchemeFromUI();" style="display: inline-block;">
<option value="auto" {% if web_ui_options["color-scheme-default"] == "auto" %}selected{% end %}>Automatic</option>
<option value="light" {% if web_ui_options["color-scheme-default"] == "light" %}selected{% end %}>Light</option>
+10
View File
@@ -0,0 +1,10 @@
<div class="card-header">
<div class="row">
<div class="col-auto me-auto">
Data
</div>
<div class="col-auto d-inline-flex">
<button id="close-data-button" type="button" class="btn-close btn-close" aria-label="Close" onclick="closeDataPanel();"></button>
</div>
</div>
</div>
@@ -1,4 +0,0 @@
<div class="d-inline-flex gap-1">
<button id="filters-button" type="button" class="btn btn-outline-secondary" data-bs-toggle="button" onclick="toggleFiltersPanel();"><i class="fa-solid fa-filter"></i>&nbsp;Filters</button>
<button id="display-button" type="button" class="btn btn-outline-secondary" data-bs-toggle="button" onclick="toggleDisplayPanel();"><i class="fa-solid fa-desktop"></i>&nbsp;Display</button>
</div>
@@ -0,0 +1,5 @@
<div class="d-inline-flex gap-1">
<button id="filters-button" type="button" class="btn btn-outline-secondary" data-bs-toggle="button" onclick="toggleFiltersPanel();"><i class="fa-solid fa-filter"></i><span class="hideonmobile">&nbsp;Filters</span></button>
<button id="display-button" type="button" class="btn btn-outline-secondary" data-bs-toggle="button" onclick="toggleDisplayPanel();"><i class="fa-solid fa-desktop"></i><span class="hideonmobile">&nbsp;Display</span></button>
<button id="data-button" type="button" class="btn btn-outline-secondary" data-bs-toggle="button" onclick="toggleDataPanel();"><i class="fa-solid fa-database"></i><span class="hideonmobile">&nbsp;Your data</span></button>
</div>
+153 -9
View File
@@ -13,6 +13,10 @@ info:
## Changelog
### 1.3
* `/spots`, `/spots/stream`, `/alerts`, `/alerts/stream`, and `/lookup/call` now accept optional QRZ.com and HamQTH credentials as query parameters. When supplied, returned data is enriched with operator name, home location etc. from those services.
### 1.2
* Added `/dxstats` endpoint for inter-continent DX spot statistics.
@@ -29,7 +33,7 @@ info:
license:
name: The Unlicense
url: https://unlicense.org/#the-unlicense
version: v1.2
version: v1.3
servers:
- url: https://spothole.app/api/v1
paths:
@@ -38,7 +42,7 @@ paths:
tags:
- Spots
summary: Get spots
description: The main API call that retrieves spots from the system. Supply this with no query parameters to retrieve all spots known to the system. Supply query parameters to filter what is retrieved.
description: The main API call that retrieves spots from the system. Supply this with no query parameters to retrieve all spots known to the system. Supply query parameters to filter what is retrieved. If QRZ.com or HamQTH credentials are supplied, returned spots will be enriched with operator name, home location etc. from those services.
operationId: spots
parameters:
- name: limit
@@ -160,6 +164,12 @@ paths:
schema:
type: boolean
default: true
- $ref: '#/components/parameters/QrzUsername'
- $ref: '#/components/parameters/QrzPassword'
- $ref: '#/components/parameters/QrzSessionKey'
- $ref: '#/components/parameters/HamqthUsername'
- $ref: '#/components/parameters/HamqthPassword'
- $ref: '#/components/parameters/HamqthSessionId'
responses:
'200':
description: Success
@@ -175,7 +185,7 @@ paths:
tags:
- Spots
summary: Get spot stream
description: Request a Server-Sent Event stream which will return individual spots immediately when they are added to the system. Only spots that match the provided filters will be returned.
description: Request a Server-Sent Event stream which will return individual spots immediately when they are added to the system. Only spots that match the provided filters will be returned. If QRZ.com or HamQTH credentials are supplied, streamed spots will be enriched with operator name, home location etc. from those services.
operationId: spots-stream
parameters:
- name: source
@@ -266,6 +276,12 @@ paths:
schema:
type: boolean
default: true
- $ref: '#/components/parameters/QrzUsername'
- $ref: '#/components/parameters/QrzPassword'
- $ref: '#/components/parameters/QrzSessionKey'
- $ref: '#/components/parameters/HamqthUsername'
- $ref: '#/components/parameters/HamqthPassword'
- $ref: '#/components/parameters/HamqthSessionId'
responses:
'200':
description: Success
@@ -280,7 +296,7 @@ paths:
tags:
- Alerts
summary: Get alerts
description: Retrieves alerts (indications of upcoming activations) from the system. Supply this with no query parameters to retrieve all alerts known to the system. Supply query parameters to filter what is retrieved.
description: Retrieves alerts (indications of upcoming activations) from the system. Supply this with no query parameters to retrieve all alerts known to the system. Supply query parameters to filter what is retrieved. If QRZ.com or HamQTH credentials are supplied, returned alerts will be enriched with operator names from those services.
operationId: alerts
parameters:
- name: limit
@@ -294,7 +310,7 @@ paths:
description: Limit the alerts to only ones that the system found out about at this time or later. Time in UTC seconds since UNIX epoch. If you are using a front-end that tracks the last time it queried the API and requests alerts since then, you want *this* version of the query parameter, not "since", because otherwise it may miss things. The logic is "greater than" rather than "greater than or equal to", so you can submit the time of the last received item back to this call and you will get all the more recent alerts back, without duplicating the previous latest spot.
required: false
schema:
type: integer
type: number
- name: max_duration
in: query
description: Limit the alerts to only ones with a duration of this many seconds or less. Duration is end time minus start time, if end time is set, otherwise the activation is assumed to be short and therefore to always pass this check. This is useful to filter out people who alert POTA activations lasting months or even years, but note it will also include multi-day or multi-week DXpeditions that you might otherwise be interested in. See the dxpeditions_skip_max_duration_check parameter for the workaround.
@@ -337,6 +353,12 @@ paths:
required: false
schema:
type: string
- $ref: '#/components/parameters/QrzUsername'
- $ref: '#/components/parameters/QrzPassword'
- $ref: '#/components/parameters/QrzSessionKey'
- $ref: '#/components/parameters/HamqthUsername'
- $ref: '#/components/parameters/HamqthPassword'
- $ref: '#/components/parameters/HamqthSessionId'
responses:
'200':
description: Success
@@ -353,7 +375,7 @@ paths:
tags:
- Alerts
summary: Get alert stream
description: Request a Server-Sent Event stream which will return individual alerts immediately when they are added to the system. Only alerts that match the provided filters will be returned.
description: Request a Server-Sent Event stream which will return individual alerts immediately when they are added to the system. Only alerts that match the provided filters will be returned. If QRZ.com or HamQTH credentials are supplied, streamed alerts will be enriched with operator names from those services.
operationId: alerts-stream
parameters:
- name: max_duration
@@ -398,6 +420,12 @@ paths:
required: false
schema:
type: string
- $ref: '#/components/parameters/QrzUsername'
- $ref: '#/components/parameters/QrzPassword'
- $ref: '#/components/parameters/QrzSessionKey'
- $ref: '#/components/parameters/HamqthUsername'
- $ref: '#/components/parameters/HamqthPassword'
- $ref: '#/components/parameters/HamqthSessionId'
responses:
'200':
description: Success
@@ -607,7 +635,7 @@ paths:
tags:
- Utilities
summary: Look up callsign details
description: Perform a lookup of data about a certain callsign, using any of the lookup services available to the Spothole server.
description: Perform a lookup of data about a certain callsign, using any of the lookup services available to the Spothole server. If QRZ.com or HamQTH credentials are supplied, the response will be able to use these services to perform a lookup.
operationId: call
parameters:
- name: call
@@ -616,6 +644,12 @@ paths:
required: true
type: string
example: M0TRT
- $ref: '#/components/parameters/QrzUsername'
- $ref: '#/components/parameters/QrzPassword'
- $ref: '#/components/parameters/QrzSessionKey'
- $ref: '#/components/parameters/HamqthUsername'
- $ref: '#/components/parameters/HamqthPassword'
- $ref: '#/components/parameters/HamqthSessionId'
responses:
'200':
description: Success
@@ -838,6 +872,50 @@ paths:
example: "Failed"
components:
parameters:
QrzUsername:
name: qrz_username
in: query
description: "QRZ.com username for online callsign lookup, which will enrich the returned spots and alerts with extra data. Requires a QRZ.com XML Subscriber (paid) account. Supply together with `qrz_password`, or supply `qrz_session_key` instead."
required: false
schema:
type: string
QrzPassword:
name: qrz_password
in: query
description: "QRZ.com password. Supply together with `qrz_username`."
required: false
schema:
type: string
QrzSessionKey:
name: qrz_session_key
in: query
description: "A pre-obtained QRZ.com XML session key, as an alternative to supplying `qrz_username` and `qrz_password`. See https://www.qrz.com/docs/xml/current_spec.html for details on how to obtain one for the user."
required: false
schema:
type: string
HamqthUsername:
name: hamqth_username
in: query
description: "HamQTH username for online callsign lookup, which will enrich the returned spots and alerts with extra data. Supply together with `hamqth_password`, or supply `hamqth_session_id` instead."
required: false
schema:
type: string
HamqthPassword:
name: hamqth_password
in: query
description: "HamQTH password. Supply together with `hamqth_username`."
required: false
schema:
type: string
HamqthSessionId:
name: hamqth_session_id
in: query
description: "A pre-obtained HamQTH session ID, as an alternative to supplying `hamqth_username` and `hamqth_password`. See https://www.hamqth.com/developers.php for details on how to retrieve one for a user."
required: false
schema:
type: string
schemas:
Source:
type: string
@@ -853,6 +931,7 @@ components:
- WOTA
- LLOTA
- WWTOTA
- Tiles
- Cluster
- RBN
- APRS-IS
@@ -880,6 +959,7 @@ components:
- BOTA
- LLOTA
- WWTOTA
- Tiles
- WAB
- WAI
- TOTA
@@ -906,6 +986,7 @@ components:
- BOTA
- LLOTA
- WWTOTA
- Tiles
- WAB
- WAI
- TOTA
@@ -1415,7 +1496,7 @@ components:
type: integer
description: 3-hour geomagnetic activity index, 09
example: 2
x_ray:
xray:
type: string
description: Current X-ray flux class
example: "B2.3"
@@ -1507,10 +1588,73 @@ components:
type: string
description: Sporadic-E propagation condition on 2m for North America
example: "Band Closed"
blackout_desc:
k_index_forecast:
type: object
description: >
NOAA Kp index 3-day forecast. Keys are UNIX timestamps (UTC seconds since epoch) for the
start of each 3-hour period. Values are the forecast Kp index (09) for that period.
Only forecast values are included; observed actuals (shown in parentheses in the source
data) are discarded.
additionalProperties:
type: number
minimum: 0
maximum: 9
example:
"1743638400.0": 4.0
"1743649200.0": 5.67
"1743660000.0": 3.67
solar_storm_forecast:
type: object
description: >
NOAA Solar Radiation Storm forecast — probability (%) of S1 or greater events per day.
Keys are UNIX timestamps (UTC seconds since epoch) for the start of each forecast day.
Values are integer percentages (0100).
additionalProperties:
type: integer
minimum: 0
maximum: 100
example:
"1743638400.0": 50
"1743724800.0": 50
"1743811200.0": 25
blackout_forecast_r1r2:
type: object
description: >
NOAA Radio Blackout forecast — probability (%) of R1R2 (MinorModerate) blackout events
per day. Keys are UNIX timestamps (UTC seconds since epoch) for the start of each
forecast day. Values are integer percentages (0100).
additionalProperties:
type: integer
minimum: 0
maximum: 100
example:
"1743638400.0": 55
"1743724800.0": 55
"1743811200.0": 55
blackout_forecast_r3_or_greater:
type: object
description: >
NOAA Radio Blackout forecast — probability (%) of R3 or greater (StrongExtreme) blackout
events per day. Keys are UNIX timestamps (UTC seconds since epoch) for the start of each
forecast day. Values are integer percentages (0100).
additionalProperties:
type: integer
minimum: 0
maximum: 100
example:
"1743638400.0": 25
"1743724800.0": 25
"1743811200.0": 25
xray_desc:
type: string
description: HF radio blackout risk description, derived from the X-ray flux class.
example: "No significant radio blackout"
radio_blackout_scale:
type: integer
description: HF radio blackout scale number (R0-R5), derived from the X-ray flux class.
minimum: 0
maximum: 5
example: 0
proton_flux_desc:
type: string
description: Solar radiation storm level description, derived from proton flux.
Binary file not shown.
+2 -9
View File
@@ -100,11 +100,6 @@ div.appearing-panel {
display: none;
}
.spothole-card-text {
line-height: 2.5em !important;
}
/* SPOTS/ALERTS PAGES, MAIN TABLE */
@@ -219,10 +214,8 @@ div#map {
.leaflet-container {
font-family: var(--bs-body-font-family) !important;
}
[data-bs-theme=dark] .leaflet-layer,
[data-bs-theme=dark] .leaflet-control-attribution {
filter: invert(100%) hue-rotate(180deg) brightness(95%) contrast(90%);
.leaflet-control-attribution {
background: none;
}
/* Make buttons overlaid on the map have a non-transparent fill so you can see the text better */
+1
View File
@@ -33,6 +33,7 @@ function buildQueryString() {
if ($("#dxpeditions_skip_max_duration_check")[0].checked) {
str = str + "&dxpeditions_skip_max_duration_check=true";
}
str = str + getCredentialQueryString();
return str;
}
+1
View File
@@ -34,6 +34,7 @@ function buildQueryString() {
str = str + "max_age=" + $("#max-spot-age option:selected").val();
// Additional filters for the bands view: No dupes, no QRT
str = str + "&dedupe=true&allow_qrt=false";
str = str + getCredentialQueryString();
return str;
}
+101 -17
View File
@@ -2,6 +2,49 @@
var options = {};
// Last time we updated the spots/alerts list on display.
var lastUpdateTime;
// Normally load user settings from local storage, unless embedded mode is in use
let useLocalStorage = true;
// Save settings to local storage. Suppressed if "use local storage" is false.
function saveSettings() {
if (useLocalStorage) {
// Find all storeable UI elements, store a key of "element id:property name" mapped to the value of that
// property. For a checkbox, that's the "checked" property.
$(".storeable-checkbox").each(function () {
localStorage.setItem("#" + $(this)[0].id + ":checked", JSON.stringify($(this)[0].checked));
});
$(".storeable-select").each(function () {
localStorage.setItem("#" + $(this)[0].id + ":value", JSON.stringify($(this)[0].value));
});
$(".storeable-text").each(function () {
localStorage.setItem("#" + $(this)[0].id + ":value", JSON.stringify($(this)[0].value));
});
// Password fields are only saved if the corresponding "remember password" checkbox is ticked.
$(".password-field").each(function () {
var pwKey = "#" + $(this)[0].id + ":value";
var rememberCheckboxId = $(this).data("remember-checkbox");
if (rememberCheckboxId && $("#" + rememberCheckboxId)[0] && $("#" + rememberCheckboxId)[0].checked) {
localStorage.setItem(pwKey, JSON.stringify($(this)[0].value));
} else {
localStorage.removeItem(pwKey);
}
});
}
}
// Load settings from local storage and set up the filter selectors. Suppressed if "use local storage" is false.
function loadSettings() {
if (useLocalStorage) {
// Find all local storage entries and push their data to the corresponding UI element
Object.keys(localStorage).forEach(function (key) {
if (key.startsWith("#") && key.includes(":")) {
// Split the key back into an element ID and a property
var split = key.split(":");
$(split[0]).prop(split[1], JSON.parse(localStorage.getItem(key)));
}
});
}
}
// Load and apply any URL params. This is used for "embedded mode" where another site can embed a version of
// Spothole and provide its own interface options rather than using the user's saved ones. These may select things
@@ -75,9 +118,9 @@ function getQueryStringFor(parameter) {
// For a parameter, such as dx_continent, get the filter options that are currently selected in the UI.
function getSelectedFilterOptions(parameter) {
return $(".filter-button-" + parameter).filter(function() {
return $(".filter-button-" + parameter).filter(function () {
return this.checked;
}).map(function() {
}).map(function () {
return this.value;
}).get().join(",");
}
@@ -85,26 +128,26 @@ function getSelectedFilterOptions(parameter) {
// For a parameter, such as dx_continent, return true if all possible options are enabled. (In this case, we don't need
// to bother sending this as one of the query parameters to the API; no parameter provided implies "send everything".)
function allFilterOptionsSelected(parameter) {
var filter = $(".filter-button-" + parameter).filter(function() {
var filter = $(".filter-button-" + parameter).filter(function () {
return !this.checked;
}).get();
return filter.length == 0;
}
// Generate a filter card with multiple toggle buttons plus All/None buttons.
// Generate a filter card with inline checkboxes plus All/None links.
function generateMultiToggleFilterCard(elementID, filterQuery, options) {
// Create a button for each option
var $row = $('<div>');
options.forEach(o => {
$(elementID).append(`<input type="checkbox" class="btn-check filter-button-${filterQuery} storeable-checkbox" name="options" id="filter-button-${filterQuery}-${o}" value="${o}" autocomplete="off" onClick="filtersUpdated()" checked><label class="btn btn-outline-success" for="filter-button-${filterQuery}-${o}">${o}</label> `);
$row.append(`<div class="form-check form-check-inline"><input type="checkbox" class="form-check-input filter-button-${filterQuery} storeable-checkbox" id="filter-button-${filterQuery}-${o}" value="${o}" autocomplete="off" onClick="filtersUpdated()" checked><label class="form-check-label" for="filter-button-${filterQuery}-${o}">${o}</label></div>`);
});
// Create All/None buttons
$(elementID).append(` <span style="display: inline-block"><button id="filter-button-${filterQuery}-all" type="button" class="btn btn-outline-secondary" onclick="toggleFilterButtons('${filterQuery}', true);">All</button>&nbsp;<button id="filter-button-${filterQuery}-none" type="button" class="btn btn-outline-secondary" onclick="toggleFilterButtons('${filterQuery}', false);">None</button></span>`);
$(elementID).append($row);
$(elementID).append(`<div class="mt-1"><a href="#" onclick="toggleFilterButtons('${filterQuery}', true); return false;">All</a> &nbsp; <a href="#" onclick="toggleFilterButtons('${filterQuery}', false); return false;">None</a></div>`);
}
// Method called when "All" or "None" is clicked
function toggleFilterButtons(filterQuery, state) {
$(".filter-button-" + filterQuery).each(function() {
$(".filter-button-" + filterQuery).each(function () {
$(this).prop('checked', state);
});
filtersUpdated();
@@ -185,8 +228,9 @@ function listenForOSThemeChange() {
// Panel toggle functions
const PANELS = [
{ area: "#filters-area",button: "#filters-button" },
{ area: "#display-area", button: "#display-button" },
{area: "#filters-area", button: "#filters-button"},
{area: "#display-area", button: "#display-button"},
{area: "#data-area", button: "#data-button"},
];
// Toggle a panel open or closed. If opening, all other visible panels are closed first.
@@ -206,18 +250,58 @@ function togglePanel(areaId) {
// Close a panel and deactivate its toggle button.
function closePanel(areaId) {
const panel = PANELS.find(p => p.area === areaId);
if (panel) { $(panel.button).button("toggle"); }
if (panel) {
$(panel.button).button("toggle");
}
$(areaId).hide();
}
function toggleFiltersPanel() { togglePanel("#filters-area"); }
function closeFiltersPanel() { closePanel("#filters-area"); }
function toggleDisplayPanel() { togglePanel("#display-area"); }
function closeDisplayPanel() { closePanel("#display-area"); }
function toggleFiltersPanel() {
togglePanel("#filters-area");
}
function closeFiltersPanel() {
closePanel("#filters-area");
}
function toggleDisplayPanel() {
togglePanel("#display-area");
}
function closeDisplayPanel() {
closePanel("#display-area");
}
function toggleDataPanel() {
togglePanel("#data-area");
}
function closeDataPanel() {
closePanel("#data-area");
}
// Build a query string fragment containing any QRZ.com / HamQTH credentials the user has supplied,
// provided the corresponding "enabled" checkbox is ticked.
function getCredentialQueryString() {
var str = "";
if ($("#qrz-enabled")[0] && $("#qrz-enabled")[0].checked) {
var qrzUsername = $("#qrz-username").val();
var qrzPassword = $("#qrz-password").val();
if (qrzUsername) str += "&qrz_username=" + encodeURIComponent(qrzUsername);
if (qrzPassword) str += "&qrz_password=" + encodeURIComponent(qrzPassword);
}
if ($("#hamqth-enabled")[0] && $("#hamqth-enabled")[0].checked) {
var hamqthUsername = $("#hamqth-username").val();
var hamqthPassword = $("#hamqth-password").val();
if (hamqthUsername) str += "&hamqth_username=" + encodeURIComponent(hamqthUsername);
if (hamqthPassword) str += "&hamqth_password=" + encodeURIComponent(hamqthPassword);
}
return str;
}
// Startup
$(document).ready(function() {
$(document).ready(function () {
usePreferredTheme();
listenForOSThemeChange();
});
+279 -33
View File
@@ -1,17 +1,19 @@
// Cache for the full dxstats API response, so we can reload on the fly if the user changes the value of their continent
// in the select box
let dxStatsData = null;
// Forecast chart
let kpChart = null;
// Load solar conditions
function loadSolarConditions() {
$.getJSON('/api/v1/solar', function(jsonData) {
$.getJSON('/api/v1/solar', function (jsonData) {
// HF
const hfConditionClass = { 'Good': 'bg-success-subtle', 'Fair': 'bg-warning-subtle', 'Poor': 'bg-danger-subtle' };
const hfConditionClass = {'Good': 'bg-success-subtle', 'Fair': 'bg-warning-subtle', 'Poor': 'bg-danger-subtle'};
if (jsonData.hf_conditions) {
Object.entries(jsonData.hf_conditions).forEach(function([key, condition]) {
Object.entries(jsonData.hf_conditions).forEach(function ([key, condition]) {
const cell = $('#hf-conditions-' + key);
cell.text(condition);
cell.addClass(hfConditionClass[condition]);
@@ -21,7 +23,7 @@ function loadSolarConditions() {
// VHF
if (jsonData.vhf_conditions) {
Object.entries(jsonData.vhf_conditions).forEach(function([key, condition]) {
Object.entries(jsonData.vhf_conditions).forEach(function ([key, condition]) {
const cell = $('#vhf-conditions-' + key);
cell.text(condition);
let vhfClass;
@@ -42,24 +44,25 @@ function loadSolarConditions() {
// Solar Weather
const swFields = {
'sfi': 'sw-sfi',
'sunspots': 'sw-sunspots',
'sfi': 'sw-sfi',
'sunspots': 'sw-sunspots',
'band_conditions_desc': 'sw-solar-flux-desc',
'k_index': 'sw-k-index',
'a_index': 'sw-a-index',
'geomag_field': 'sw-geomag-field',
'geomag_storm_scale': 'sw-geomag-storm-scale',
'geomag_storm_desc': 'sw-geomag-storm-desc',
'geomag_noise': 'sw-geomag-noise',
'x_ray': 'sw-x-ray',
'blackout_desc': 'sw-xray-desc',
'proton_flux': 'sw-proton-flux',
'solar_storm_scale': 'sw-solar-storm-scale',
'proton_flux_desc': 'sw-proton-desc',
'electron_flux': 'sw-electron-flux',
'electron_flux_desc': 'sw-electron-desc',
'k_index': 'sw-k-index',
'a_index': 'sw-a-index',
'geomag_field': 'sw-geomag-field',
'geomag_storm_scale': 'sw-geomag-storm-scale',
'geomag_storm_desc': 'sw-geomag-storm-desc',
'geomag_noise': 'sw-geomag-noise',
'xray': 'sw-xray',
'radio_blackout_scale': 'sw-radio-blackout-scale',
'xray_desc': 'sw-xray-desc',
'proton_flux': 'sw-proton-flux',
'solar_storm_scale': 'sw-solar-storm-scale',
'proton_flux_desc': 'sw-proton-desc',
'electron_flux': 'sw-electron-flux',
'electron_flux_desc': 'sw-electron-desc',
};
Object.entries(swFields).forEach(function([field, id]) {
Object.entries(swFields).forEach(function ([field, id]) {
const val = jsonData[field];
if (val !== null && val !== undefined) {
$('#' + id).text(val);
@@ -82,15 +85,15 @@ function loadSolarConditions() {
const kIndex = jsonData.k_index;
if (kIndex !== null && kIndex !== undefined) {
applySwClass('sw-geomag-vals', 'sw-geomag-desc',
kIndex < 5 ? 'bg-success-subtle' : kIndex < 7 ? 'bg-warning-subtle' : 'bg-danger-subtle');
kIndex < 5 ? 'bg-success-subtle' : kIndex < 6 ? 'bg-warning-subtle' : 'bg-danger-subtle');
}
const xRay = jsonData.x_ray;
const xRay = jsonData.xray;
if (xRay) {
const letter = xRay[0].toUpperCase();
const xRayClass = (letter === 'X') ? 'bg-danger-subtle'
: (letter === 'M') ? 'bg-warning-subtle'
: 'bg-success-subtle';
: (letter === 'M') ? 'bg-warning-subtle'
: 'bg-success-subtle';
applySwClass('sw-xray-vals', 'sw-xray-desc', xRayClass);
}
@@ -105,30 +108,273 @@ function loadSolarConditions() {
applySwClass('sw-electron-vals', 'sw-electron-desc',
electronFlux <= 100 ? 'bg-success-subtle' : electronFlux <= 1000 ? 'bg-warning-subtle' : 'bg-danger-subtle');
}
// Forecast
renderKIndexForecast(jsonData.k_index_forecast);
renderSolarStormForecast(jsonData.solar_storm_forecast);
renderBlackoutForecast(jsonData.blackout_forecast_r1r2, jsonData.blackout_forecast_r3_or_greater);
});
}
// Render the K-index forecast as a Chart.js bar chart, one bar per 3-hour UTC period
function renderKIndexForecast(data) {
if (!data) return;
const entries = Object.entries(data)
.map(([tsStr, kp]) => ({ts: parseFloat(tsStr), kp}))
.sort((a, b) => a.ts - b.ts);
if (entries.length === 0) return;
// Use a simple integer index axis: ticks at 0, 1, 2, ..., N (period boundaries) and bars
// centred at 0.5, 1.5, ..., N-0.5 (midpoints). This guarantees tick marks fall exactly on
// bar edges regardless of how Chart.js rounds large timestamp values.
// "axisMin = 0" is the left/top edge of bar 0; "axisMax = N" is the right/bottom edge of bar N-1.
const N = entries.length;
const periodSecs = 3 * 3600;
// Inherit colours from Bootstrap CSS variables so that dark mode inherently works. We want bar colours that are not
// quite as saturated as the Bootstrap success/warning/danger colours but not as desaturated as the "subtle"
// versions, so use tinycolor to apply some transparency.
const style = getComputedStyle(document.documentElement);
const withAlpha = hex => tinycolor(hex).setAlpha(0.8).toRgbString();
const colors = entries.map(e =>
e.kp < 4.5 ? withAlpha(style.getPropertyValue('--bs-success').trim())
: e.kp < 5.5 ? withAlpha(style.getPropertyValue('--bs-warning').trim())
: withAlpha(style.getPropertyValue('--bs-danger').trim())
);
const textColor = style.getPropertyValue('--bs-body-color').trim() || '#666';
const gridColor = style.getPropertyValue('--bs-border-color').trim() || 'rgba(128,128,128,0.3)';
if (kpChart) {
kpChart.destroy();
}
const isMobile = window.innerWidth < 768;
const kpAxisTicks = {
stepSize: 1,
color: textColor,
// Include geomagnetic storm levels (Gx) alongside the Kp index
callback: v => v > 4 ? `(G${v - 4}) ${v}` : String(v),
};
const kpAxis = {
min: 0,
max: 9,
title: {display: true, text: 'Kp', color: textColor},
ticks: kpAxisTicks,
grid: {color: gridColor},
};
// Linear scale using integer indices. Ticks at 0..N (period boundary indices);
// the callback converts each integer index back to a UTC time string.
// On mobile the time axis is vertical, so reverse it to keep time running top-to-bottom.
const timeAxis = {
type: 'linear',
min: 0,
max: N,
offset: false,
reverse: isMobile,
title: {display: true, text: 'Time (UTC)', color: textColor},
ticks: {
stepSize: 1,
color: textColor,
maxRotation: 45,
minRotation: 0,
callback(value) {
if (!Number.isInteger(value) || value < 0 || value > N) return null;
const ts = value < N ? entries[value].ts : entries[N - 1].ts + periodSecs;
const dt = new Date(ts * 1000);
const h = dt.getUTCHours(), m = dt.getUTCMinutes();
const timeStr = String(h).padStart(2, '0') + ':' + String(m).padStart(2, '0');
if (h === 0 && m === 0) {
return [timeStr, dt.toLocaleDateString('en-GB', {day: '2-digit', month: 'short', timeZone: 'UTC'})];
}
return timeStr;
},
},
grid: {color: gridColor, offset: false},
};
// Draw a "now" line at the current time position
const nowLinePlugin = {
id: 'nowLine',
afterDraw(chart) {
const nowTs = Date.now() / 1000;
// Find which bar (if any) the current time falls in and compute a fractional index
const firstTs = entries[0].ts;
const lastTs = entries[N - 1].ts + periodSecs;
if (nowTs < firstTs || nowTs > lastTs) return;
const fracIndex = (nowTs - firstTs) / periodSecs;
const {ctx, chartArea} = chart;
const scale = isMobile ? chart.scales.y : chart.scales.x;
const pos = scale.getPixelForValue(fracIndex);
ctx.save();
ctx.strokeStyle = textColor;
ctx.lineWidth = 2;
ctx.setLineDash([5, 4]);
ctx.beginPath();
if (isMobile) {
ctx.moveTo(chartArea.left, pos);
ctx.lineTo(chartArea.right, pos);
} else {
ctx.moveTo(pos, chartArea.top);
ctx.lineTo(pos, chartArea.bottom);
}
ctx.stroke();
ctx.setLineDash([]);
ctx.fillStyle = textColor;
ctx.font = '11px sans-serif';
if (isMobile) {
ctx.textAlign = 'right';
ctx.textBaseline = 'bottom';
ctx.fillText('Now', chartArea.right, pos - 3);
} else {
ctx.textAlign = 'left';
ctx.textBaseline = 'top';
ctx.fillText(' Now', pos, chartArea.top + 3);
}
ctx.restore();
}
};
// Bars centred at i+0.5 (midpoint between tick i and tick i+1) so each bar spans
// exactly from tick i to tick i+1 with barPercentage/categoryPercentage = 1.0.
const chartData = isMobile
? entries.map((e, i) => ({x: e.kp, y: i + 0.5}))
: entries.map((e, i) => ({x: i + 0.5, y: e.kp}));
kpChart = new Chart(document.getElementById('forecast-kp-chart'), {
type: 'bar',
data: {
datasets: [{
data: chartData,
backgroundColor: colors,
hoverBackgroundColor: colors,
borderWidth: 0,
barPercentage: 1.0,
categoryPercentage: 1.0,
}]
},
options: {
responsive: true,
// Swap axes on mobile, and change the aspect ratio
aspectRatio: isMobile ? 0.4 : 3,
indexAxis: isMobile ? 'y' : 'x',
plugins: {
legend: {
display: false
},
tooltip: {
enabled: false
}
},
scales: {
x: isMobile ? kpAxis : timeAxis,
y: isMobile ? timeAxis : kpAxis,
}
},
plugins: [nowLinePlugin],
});
}
// Render the solar storm forecast table
function renderSolarStormForecast(data) {
if (!data) return;
const entries = Object.entries(data)
.map(([tsStr, pct]) => ({ts: parseFloat(tsStr), pct}))
.sort((a, b) => a.ts - b.ts);
// Header
const headRow = $('#forecast-solar-storm-head').empty().append('<th></th>');
entries.forEach(({ts}) => {
const label = new Date(ts * 1000)
.toLocaleDateString('en-GB', {day: '2-digit', month: 'short', timeZone: 'UTC'});
headRow.append(`<th>${label}</th>`);
});
// Single data row: "S1 or greater" label + one cell per date
const tr = $('<tr>').append('<td>S1 or greater</td>');
entries.forEach(({pct}) => {
const td = $('<td>').text(pct + '%');
td.addClass(pct < 50 ? 'bg-success-subtle' : pct < 75 ? 'bg-warning-subtle' : 'bg-danger-subtle');
tr.append(td);
});
$('#forecast-solar-storm-tbody').empty().append(tr);
}
// Render the radio blackout forecast table
function renderBlackoutForecast(r1r2Data, r3Data) {
if (!r1r2Data && !r3Data) return;
const tsSet = new Set([
...Object.keys(r1r2Data || {}),
...Object.keys(r3Data || {})
]);
const entries = [...tsSet]
.map(tsStr => ({
ts: parseFloat(tsStr),
r1r2: r1r2Data ? r1r2Data[tsStr] : undefined,
r3: r3Data ? r3Data[tsStr] : undefined
}))
.sort((a, b) => a.ts - b.ts);
// Header
const headRow = $('#forecast-blackout-head').empty().append('<th></th>');
entries.forEach(({ts}) => {
const label = new Date(ts * 1000)
.toLocaleDateString('en-GB', {day: '2-digit', month: 'short', timeZone: 'UTC'});
headRow.append(`<th>${label}</th>`);
});
// Two data rows: R1-R2 and R3+
function makeRow(rowLabel, getValue) {
const tr = $('<tr>').append(`<td>${rowLabel}</td>`);
entries.forEach(entry => {
const pct = getValue(entry);
const td = $('<td>');
if (pct !== undefined) {
td.text(pct + '%');
td.addClass(pct < 50 ? 'bg-success-subtle' : pct < 75 ? 'bg-warning-subtle' : 'bg-danger-subtle');
}
tr.append(td);
});
return tr;
}
$('#forecast-blackout-tbody').empty()
.append(makeRow('R1-R2', e => e.r1r2))
.append(makeRow('R3 or greater', e => e.r3));
}
// Render the DX stats table for the currently selected DE continent
function renderDxStats() {
if (!dxStatsData) { return; }
if (!dxStatsData) {
return;
}
const deContinent = $('#dxstats-de-continent').val();
const deData = dxStatsData[deContinent];
if (!deData) { return; }
if (!deData) {
return;
}
const cells = [];
Object.entries(deData).forEach(function([dxContinent, bands]) {
Object.entries(bands).forEach(function([band, count]) {
Object.entries(deData).forEach(function ([dxContinent, bands]) {
Object.entries(bands).forEach(function ([band, count]) {
const cell = $('#dxstats-' + dxContinent + '-' + band);
cell.text(count);
cells.push({ cell, count });
cells.push({cell, count});
});
});
const counts = cells.map(function(c) { return c.count; });
const counts = cells.map(function (c) {
return c.count;
});
const min = Math.min(...counts);
const max = Math.max(...counts);
const range = max - min;
cells.forEach(function({ cell, count }) {
cells.forEach(function ({cell, count}) {
const t = range > 0 ? (count - min) / range : 0;
const cls = t === 0 ? 'bg-danger-subtle' : t < 0.05 ? 'bg-warning-subtle' : 'bg-success-subtle';
cell.removeClass('bg-danger-subtle bg-warning-subtle bg-success-subtle').addClass(cls);
@@ -143,14 +389,14 @@ function dxStatsContientChanged() {
// Fetch DX stats from the API and render
function loadDxStats() {
$.getJSON('/api/v1/dxstats', function(jsonData) {
$.getJSON('/api/v1/dxstats', function (jsonData) {
dxStatsData = jsonData;
renderDxStats();
});
}
// Startup
$(document).ready(function() {
$(document).ready(function () {
loadSettings();
loadSolarConditions();
loadDxStats();
+242 -6
View File
@@ -1,10 +1,29 @@
// How often to query the server?
const REFRESH_INTERVAL_SEC = 60;
// Colours
const MAIDENHEAD_GRID_COLOR_LIGHT = 'rgba(200, 140, 140, 1.0)';
const CQ_ZONES_COLOR_LIGHT = 'rgba(140, 200, 140, 1.0)';
const ITU_ZONES_COLOR_LIGHT = 'rgba(200, 200, 140, 1.0)';
const WAB_WAI_GRID_COLOR_LIGHT = 'rgba(140, 140, 200, 1.0)';
const MAIDENHEAD_GRID_COLOR_DARK = 'rgba(120, 60, 60, 1.0)';
const CQ_ZONES_COLOR_DARK = 'rgba(60, 120, 60, 1.0)';
const ITU_ZONES_COLOR_DARK = 'rgba(120, 120, 60, 1.0)';
const WAB_WAI_GRID_COLOR_DARK = 'rgba(60, 60, 120, 1.0)';
// Map layers
var backgroundTileLayer;
var markersLayer;
var geodesicsLayer;
var terminator;
var maidenheadGrid;
var cqZones;
var ituZones;
var wabwaiGrid;
// Tracks the currently-loaded basemap provider string to avoid unnecessary tile reloads
var loadedBasemap;
// Tracks whether this is the first display of markers after page load
var firstLoad = true;
// Load spots and populate the map.
function loadSpots() {
@@ -13,7 +32,9 @@ function loadSpots() {
spots = jsonData;
// Update map
updateMap();
terminator.setTime();
if ($("#showTerminator")[0].checked) {
terminator.setTime();
}
});
}
@@ -28,6 +49,7 @@ function buildQueryString() {
str = str + "max_age=" + $("#max-spot-age option:selected").val();
// Additional filters for the map view: No dupes, no QRT, only spots with good locations
str = str + "&dedupe=true&allow_qrt=false&needs_good_location=true";
str = str + getCredentialQueryString();
return str;
}
@@ -57,6 +79,15 @@ function updateMap() {
}
}
});
// On first load, zoom to the extent of the markers
if (firstLoad) {
if (markersLayer.getLayers().length >= 2) {
var group = new L.featureGroup(markersLayer.getLayers());
map.fitBounds(group.getBounds().pad(0.1));
}
firstLoad = false;
}
}
// Get an icon for a spot, based on its band, using PSK Reporter colours, its program etc.
@@ -180,10 +211,27 @@ function loadOptions() {
// to be called after these are set up, but if the URL params ask for "embedded mode", this will suppress
// loading settings, so this needs to be called before that.
loadURLParams();
loadMapURLParams();
// Load settings from settings storage now all the controls are available
loadSettings();
// If no basemap has been explicitly saved and the UI is in dark mode, default to dark Mapnik
if (localStorage.getItem("#basemap:value") === null) {
if (document.documentElement.getAttribute("data-bs-theme") === "dark") {
$("#basemap").val("OpenStreetMap.Mapnik.Dark");
}
}
// Apply basemap and overlay settings now that controls have their saved values
setBasemap($("#basemap").val());
setBasemapOpacity(parseFloat($("#basemapOpacity").val()));
enableTerminator($("#showTerminator")[0].checked);
enableMaidenheadGrid($("#showMaidenheadGrid")[0].checked);
enableCQZones($("#showCQZones")[0].checked);
enableITUZones($("#showITUZones")[0].checked);
enableWABWAIGrid($("#showWABWAIGrid")[0].checked);
// Load spots and set up the timer
loadSpots();
setInterval(loadSpots, REFRESH_INTERVAL_SEC * 1000);
@@ -193,9 +241,150 @@ function loadOptions() {
// Method called when any display property is changed to reload the map and persist the display settings.
function displayUpdated() {
updateMap();
setBasemap($("#basemap").val());
setBasemapOpacity(parseFloat($("#basemapOpacity").val()));
enableTerminator($("#showTerminator")[0].checked);
enableMaidenheadGrid($("#showMaidenheadGrid")[0].checked);
enableCQZones($("#showCQZones")[0].checked);
enableITUZones($("#showITUZones")[0].checked);
enableWABWAIGrid($("#showWABWAIGrid")[0].checked);
saveSettings();
}
// Set the basemap
function setBasemap(basemapname) {
// Only change if we have to, to avoid a flash of reloading content
if (loadedBasemap !== basemapname) {
loadedBasemap = basemapname;
if (typeof backgroundTileLayer !== 'undefined') {
map.removeLayer(backgroundTileLayer);
}
// OpenStreetMap.Mapnik.Dark is a synthetic variant that uses Mapnik tiles with a CSS filter applied
const providerName = basemapname === "OpenStreetMap.Mapnik.Dark" ? "OpenStreetMap.Mapnik" : basemapname;
backgroundTileLayer = L.tileLayer.provider(providerName, {
opacity: parseFloat($("#basemapOpacity").val()),
edgeBufferTiles: 1
});
backgroundTileLayer.addTo(map);
backgroundTileLayer.bringToBack();
if (basemapname === "OpenStreetMap.Mapnik.Dark") {
var container = backgroundTileLayer.getContainer();
if (container) {
container.style.filter = 'invert(100%) hue-rotate(180deg) brightness(80%)';
}
}
// Identify dark basemaps to ensure we use white text for unselected icons
// and change the background colour appropriately
const basemapIsDark = basemapname === "CartoDB.DarkMatter" || basemapname === "Esri.WorldImagery" || basemapname === "OpenStreetMap.Mapnik.Dark";
$("#map").css('background-color', basemapIsDark ? "black" : "white");
// Change the colour of the grid and zone overlays to match
if (basemapIsDark) {
maidenheadGrid.options.color = MAIDENHEAD_GRID_COLOR_DARK;
cqZones.options.color = CQ_ZONES_COLOR_DARK;
ituZones.options.color = ITU_ZONES_COLOR_DARK;
wabwaiGrid.options.color = WAB_WAI_GRID_COLOR_DARK;
} else {
maidenheadGrid.options.color = MAIDENHEAD_GRID_COLOR_LIGHT;
cqZones.options.color = CQ_ZONES_COLOR_LIGHT;
ituZones.options.color = ITU_ZONES_COLOR_LIGHT;
wabwaiGrid.options.color = WAB_WAI_GRID_COLOR_LIGHT;
}
// Force regenerate overlays in the new colours
map.removeLayer(maidenheadGrid);
map.removeLayer(cqZones);
map.removeLayer(ituZones);
map.removeLayer(wabwaiGrid);
enableMaidenheadGrid($("#showMaidenheadGrid")[0].checked);
enableCQZones($("#showCQZones")[0].checked);
enableITUZones($("#showITUZones")[0].checked);
enableWABWAIGrid($("#showWABWAIGrid")[0].checked);
}
}
// Set the basemap opacity
function setBasemapOpacity(opacity) {
if (typeof backgroundTileLayer !== 'undefined') {
backgroundTileLayer.setOpacity(opacity);
}
}
// Shows/hides the terminator/greyline overlay
function enableTerminator(show) {
if (show) {
terminator.setTime();
terminator.addTo(map);
} else {
map.removeLayer(terminator);
}
}
// Shows/hides the Maidenhead grid overlay
function enableMaidenheadGrid(show) {
if (show) {
maidenheadGrid.addTo(map);
backgroundTileLayer.bringToBack();
} else {
map.removeLayer(maidenheadGrid);
}
}
// Shows/hides the CQ zone overlay
function enableCQZones(show) {
if (show) {
cqZones.addTo(map);
backgroundTileLayer.bringToBack();
} else {
map.removeLayer(cqZones);
}
}
// Shows/hides the ITU zone overlay
function enableITUZones(show) {
if (show) {
ituZones.addTo(map);
backgroundTileLayer.bringToBack();
} else {
map.removeLayer(ituZones);
}
}
// Shows/hides the WAB/WAI grid overlay
function enableWABWAIGrid(show) {
if (show) {
wabwaiGrid.addTo(map);
backgroundTileLayer.bringToBack();
} else {
map.removeLayer(wabwaiGrid);
}
}
// Load map-specific URL parameters for center position and zoom level.
// These set Leaflet state directly rather than form controls, so they live here rather than in loadURLParams().
// If any parameter is applied, firstLoad is set to false so updateMap() does not override the position.
function loadMapURLParams() {
let params = new URLSearchParams(document.location.search);
let lat = parseFloat(params.get("map-center-lat"));
let lon = parseFloat(params.get("map-center-lon"));
let zoom = parseFloat(params.get("map-zoom"));
let hasLatLon = !isNaN(lat) && !isNaN(lon);
let hasZoom = !isNaN(zoom);
if (hasLatLon || hasZoom) {
if (hasLatLon && hasZoom) {
map.setView([lat, lon], zoom);
} else if (hasLatLon) {
map.setView([lat, lon], map.getZoom());
} else {
map.setZoom(zoom);
}
firstLoad = false;
}
}
// Set up the map
function setUpMap() {
// Create map
@@ -206,12 +395,20 @@ function setUpMap() {
});
// Add basemap
backgroundTileLayer = L.tileLayer.provider("OpenStreetMap.Mapnik", {
opacity: 1,
loadedBasemap = $("#basemap").val();
const initialProviderName = loadedBasemap === "OpenStreetMap.Mapnik.Dark" ? "OpenStreetMap.Mapnik" : loadedBasemap;
backgroundTileLayer = L.tileLayer.provider(initialProviderName, {
opacity: parseFloat($("#basemapOpacity").val()),
edgeBufferTiles: 1
});
backgroundTileLayer.addTo(map);
backgroundTileLayer.bringToBack();
if (loadedBasemap === "OpenStreetMap.Mapnik.Dark") {
var container = backgroundTileLayer.getContainer();
if (container) {
container.style.filter = 'invert(100%) hue-rotate(180deg) brightness(80%)';
}
}
// Add marker layer
markersLayer = new L.LayerGroup();
@@ -221,14 +418,53 @@ function setUpMap() {
geodesicsLayer = new L.LayerGroup();
geodesicsLayer.addTo(map);
// Add terminator/greyline
// Add terminator/greyline (toggleable)
terminator = L.terminator({
interactive: false
});
terminator.setStyle({fillColor: '#00000050'});
terminator.addTo(map);
if ($("#showTerminator")[0].checked) {
terminator.addTo(map);
}
// Display a default view.
// Add Maidenhead grid (toggleable)
maidenheadGrid = L.maidenhead({
color : MAIDENHEAD_GRID_COLOR_LIGHT
});
if ($("#showMaidenheadGrid")[0].checked) {
maidenheadGrid.addTo(map);
backgroundTileLayer.bringToBack();
}
// Add CQ zone layer (toggleable)
cqZones = L.cqzones({
color : CQ_ZONES_COLOR_LIGHT
});
if ($("#showCQZones")[0].checked) {
cqZones.addTo(map);
backgroundTileLayer.bringToBack();
}
// Add ITU zone layer (toggleable)
ituZones = L.ituzones({
color : ITU_ZONES_COLOR_LIGHT
});
if ($("#showITUZones")[0].checked) {
ituZones.addTo(map);
backgroundTileLayer.bringToBack();
}
// Add WAB/WAI grid layer (toggleable)
wabwaiGrid = L.workedAllBritainIreland({
color : WAB_WAI_GRID_COLOR_LIGHT
});
if ($("#showWABWAIGrid")[0].checked) {
wabwaiGrid.addTo(map);
backgroundTileLayer.bringToBack();
}
// Display a default view. This will only last until the spots are first loaded, at which point the map will zoom
// to the extent of ths spots.
map.setView([30, 0], 3);
}
+6
View File
@@ -61,6 +61,11 @@ function startSSEConnection() {
// Add the new spot to table
addSpotToTopOfTable(newSpot, true);
// Ping if we need to
if ($("#pingOnNewSpots")[0].checked) {
new Audio("/audio/ping.mp3").play();
}
};
evtSource.onerror = function(err) {
@@ -84,6 +89,7 @@ function buildQueryString() {
if ($("#search").val() != "") {
str = str + "&text_includes=" + encodeURIComponent($("#search").val());
}
str = str + getCredentialQueryString();
return str;
}
+35 -50
View File
@@ -6,27 +6,26 @@ var spots = []
// to localStorage.
let worked = []
// Dynamically add CSS code for the band toggle buttons to be in the appropriate colour.
// Dynamically add CSS code for the band checkboxes to show in the appropriate colour.
// Some band names contain decimal points which are not allowed in CSS classes, so we text-replace them to "p".
function addBandToggleColourCSS(band_options) {
var $style = $('<style>');
band_options.forEach(o => {
var domSafeName = o["name"].replace(/^[^A-Za-z0-9]+|[^\w]+/gi, "");
$style.append(`#filter-button-label-band-${domSafeName} { border-color: ${bandToColor(o['name'])}; color: var(--bs-secondary);}`);
$style.append(`.btn-check:checked + #filter-button-label-band-${domSafeName} { background-color: ${bandToColor(o['name'])}; color: ${bandToContrastColor(o['name'])};}`);
$style.append(`#filter-button-label-band-${domSafeName} { padding-left: 0.3em; border-left: 5px solid ${bandToColor(o['name'])};}`);
});
$('html > head').append($style);
}
// Generate bands filter card. This one is a special case.
function generateBandsMultiToggleFilterCard(band_options) {
// Create a button for each option
var $grid = $('<div class="row row-cols-3 row-cols-md-2 row-cols-lg-3 row-cols-xxl-4 g-1 mb-1">');
band_options.forEach(o => {
var domSafeName = o["name"].replace(/^[^A-Za-z0-9]+|[^\w]+/gi, "");
$("#band-options").append(`<input type="checkbox" class="btn-check filter-button-band storeable-checkbox" name="options" id="filter-button-band-${domSafeName}" value="${o['name']}" autocomplete="off" onClick="filtersUpdated()" checked><label class="btn btn-outline-secondary" id="filter-button-label-band-${domSafeName}" for="filter-button-band-${domSafeName}">${o['name']}</label> `);
$grid.append(`<div class="col"><div class="form-check"><input type="checkbox" class="form-check-input filter-button-band storeable-checkbox" id="filter-button-band-${domSafeName}" value="${o['name']}" autocomplete="off" onClick="filtersUpdated()" checked> <label class="form-check-label" id="filter-button-label-band-${domSafeName}" for="filter-button-band-${domSafeName}">${o['name']}</label></div></div>`);
});
// Create All/None/Ham HF buttons
$("#band-options").append(` <span style="display: inline-block"><button id="filter-button-band-all" type="button" class="btn btn-outline-secondary" onclick="toggleFilterButtons('band', true);">All</button> <button id="filter-button-band-none" type="button" class="btn btn-outline-secondary" onclick="toggleFilterButtons('band', false);">None</button> <button id="filter-button-band-none" type="button" class="btn btn-outline-secondary" onclick="setHamHFBandToggles();">Ham HF</button></span>`);
$("#band-options").append($grid);
$("#band-options").append(`<div class="mt-1"><a href="#" onclick="toggleFilterButtons('band', true); return false;">All</a> &nbsp; <a href="#" onclick="toggleFilterButtons('band', false); return false;">None</a> &nbsp; <a href="#" onclick="setHamHFBandToggles(); return false;">Ham HF only</a></div>`);
}
// Set the band toggles so that only the amateur radio HF bands are selected. This includes 160m and 6m because that's
@@ -41,72 +40,58 @@ function setHamHFBandToggles() {
// Generate SIGs filter card. This one is also a special case.
function generateSIGsMultiToggleFilterCard(sig_options) {
// Create a button for each option
var $grid = $('<div class="row row-cols-2 row-cols-xxl-3 g-1 mb-1">');
sig_options.forEach(o => {
var domSafeName = o["name"].replace(/^[^A-Za-z0-9]+|[^\w]+/gi, "");
$("#sig-options").append(`<input type="checkbox" class="btn-check filter-button-sig storeable-checkbox" name="options" id="filter-button-sig-${domSafeName}" value="${o['name']}" autocomplete="off" onClick="filtersUpdated()" checked><label class="btn btn-outline-success" id="filter-button-label-sig-${domSafeName}" for="filter-button-sig-${domSafeName}" title="${o['description']}"><i class="fa-solid ${sigToIcon(o['name'], 'fa-tower-cell')}"></i> ${o['name']}</label> `);
$grid.append(`<div class="col"><div class="form-check"><input type="checkbox" class="form-check-input filter-button-sig storeable-checkbox" id="filter-button-sig-${domSafeName}" value="${o['name']}" autocomplete="off" onClick="filtersUpdated()" checked><label class="form-check-label" id="filter-button-label-sig-${domSafeName}" for="filter-button-sig-${domSafeName}" title="${o['description']}"><i class="fa-solid ${sigToIcon(o['name'], 'fa-tower-cell')}"></i> ${o['name']}</label></div></div>`);
});
// Create a bonus "NO_SIG" / "General DX" option
$("#sig-options").append(`<input type="checkbox" class="btn-check filter-button-sig storeable-checkbox" name="options" id="filter-button-sig-NO_SIG" value="NO_SIG" autocomplete="off" onClick="filtersUpdated()" checked><label class="btn btn-outline-success" id="filter-button-label-sig-NO_SIG" for="filter-button-sig-NO_SIG"><i class="fa-solid fa-tower-cell"></i> General DX</label> `);
// Create All/None buttons
$("#sig-options").append(` <span style="display: inline-block"><button id="filter-button-sig-all" type="button" class="btn btn-outline-secondary" onclick="toggleFilterButtons('sig', true);">All</button>&nbsp;<button id="filter-button-sig-none" type="button" class="btn btn-outline-secondary" onclick="toggleFilterButtons('sig', false);">None</button></span>`);
// Bonus "NO_SIG" / "General DX" option
$grid.append(`<div class="w-100"><div class="form-check"><input type="checkbox" class="form-check-input filter-button-sig storeable-checkbox" id="filter-button-sig-NO_SIG" value="NO_SIG" autocomplete="off" onClick="filtersUpdated()" checked><label class="form-check-label" id="filter-button-label-sig-NO_SIG" for="filter-button-sig-NO_SIG"><i class="fa-solid fa-tower-cell"></i> General DX</label></div></div>`);
$("#sig-options").append($grid);
$("#sig-options").append(`<div class="mt-1"><a href="#" onclick="toggleFilterButtons('sig', true); return false;">All</a> &nbsp; <a href="#" onclick="toggleFilterButtons('sig', false); return false;">None</a></div>`);
}
// Generate modes filter card. This one is also a special case.
function generateModesMultiToggleFilterCard(mode_options) {
// Create a button for each option
var $grid = $('<div class="row row-cols-3 row-cols-md-2 row-cols-lg-3 g-1 mb-1">');
mode_options.forEach(o => {
var domSafeName = o.replace(/^[^A-Za-z0-9]+|[^\w]+/gi, "");
$("#mode-options").append(`<input type="checkbox" class="btn-check filter-button-mode storeable-checkbox" name="options" id="filter-button-mode-${domSafeName}" value="${o}" autocomplete="off" onClick="filtersUpdated()" checked><label class="btn btn-outline-success" id="filter-button-label-mode-${domSafeName}" for="filter-button-mode-${domSafeName}">${o}</label> `);
$grid.append(`<div class="col"><div class="form-check"><input type="checkbox" class="form-check-input filter-button-mode storeable-checkbox" id="filter-button-mode-${domSafeName}" value="${o}" autocomplete="off" onClick="filtersUpdated()" checked><label class="form-check-label" id="filter-button-label-mode-${domSafeName}" for="filter-button-mode-${domSafeName}">${o}</label></div></div>`);
});
// Create All/None buttons
$("#mode-options").append(` <span style="display: inline-block"><button id="filter-button-mode-all" type="button" class="btn btn-outline-secondary" onclick="toggleFilterButtons('mode', true);">All</button>&nbsp;<button id="filter-button-mode-none" type="button" class="btn btn-outline-secondary" onclick="toggleFilterButtons('mode', false);">None</button></span>`);
// Create category buttons
$("#mode-options").append(` <button id="filter-button-mode-av" type="button" class="btn btn-outline-secondary" onclick="toggleAnalogVoiceModeToggles();">Analog Voice</button>&nbsp;<button id="filter-button-mode-dv" type="button" class="btn btn-outline-secondary" onclick="toggleDigitalVoiceModeToggles();">Digital Voice</button>&nbsp;<button id="filter-button-mode-digi" type="button" class="btn btn-outline-secondary" onclick="toggleDigiModeToggles();">Digimodes</button></span>`);
$("#mode-options").append($grid);
$("#mode-options").append(`<div class="mt-1"><a href="#" onclick="toggleFilterButtons('mode', true); return false;">All</a> &nbsp; <a href="#" onclick="toggleFilterButtons('mode', false); return false;">None</a> &nbsp; <a href="#" onclick="setVoiceModeToggles(); return false;">Voice only</a> &nbsp; <a href="#" onclick="setDigiModeToggles(); return false;">Digimodes only</a></div>`);
}
// Toggle the mode toggles that relate to Analog Voice.
function toggleAnalogVoiceModeToggles() {
toggleToggles("mode", ["PHONE", "SSB", "LSB", "USB", "AM", "FM"]);
}
// Toggle the mode toggles that relate to Digital Voice.
function toggleDigitalVoiceModeToggles() {
toggleToggles("mode", ["DV", "DMR", "DSTAR", "C4FM", "M17"]);
}
// Toggle the mode toggles that relate to Digimodes.
function toggleDigiModeToggles() {
toggleToggles("mode", ["DATA", "FT8", "FT4", "RTTY", "SSTV", "JS8", "HELL", "PSK", "OLIVIA", "PKT", "MSK144"]);
}
// Toggle the a set of toggles of the given type (e.g. "mode") that match the given values (e.g. ["SSB", "AM", "FM"]).
function toggleToggles(type, values) {
let toggle = null;
$(".filter-button-" + type).each(function() {
console.log($(this));
if (values.includes($(this).val().replace("filter-button-" + type, ""))) {
if (toggle == null) {
toggle = !$(this).prop('checked');
}
$(this).prop('checked', toggle);
}
// Set the mode toggles that relate to Analog Voice.
function setVoiceModeToggles() {
const modes = ["PHONE", "SSB", "LSB", "USB", "AM", "FM", "DV", "DMR", "DSTAR", "C4FM", "M17"];
$(".filter-button-mode").each(function() {
$(this).prop('checked', modes.includes($(this).val().replace("filter-button-mode-", "")));
});
filtersUpdated();
}
// Generate Sources filter card. This one is a minor special case as we create the buttons in the normal way, but then
// Set the mode toggles that relate to Digimodes.
function setDigiModeToggles() {
const modes = ["DATA", "FT8", "FT4", "RTTY", "SSTV", "JS8", "HELL", "PSK", "OLIVIA", "PKT", "MSK144"];
$(".filter-button-mode").each(function() {
$(this).prop('checked', modes.includes($(this).val().replace("filter-button-mode-", "")));
});
filtersUpdated();
}
// Generate Sources filter card. This one is a minor special case as we create the checkboxes in the normal way, but
// set which ones are enabled by default based on config rather than having them all enabled by default. We also sanitise
// names here for HTML elements.
function generateSourcesMultiToggleFilterCard(source_options, sources_enabled_by_default) {
// Create a button for each option
var $grid = $('<div class="row row-cols-2 row-cols-xxl-3 g-1 mb-1">');
source_options.forEach(o => {
var enable = sources_enabled_by_default.includes(o);
var domSafeName = o.replace(/^[^A-Za-z0-9]+|[^\w]+/gi, "");
$("#source-options").append(`<input type="checkbox" class="btn-check filter-button-source storeable-checkbox" name="options" id="filter-button-source-${domSafeName}" value="${o}" autocomplete="off" onClick="filtersUpdated()" ${enable ? "checked" : ""}><label class="btn btn-outline-success" for="filter-button-source-${domSafeName}">${o}</label> `);
$grid.append(`<div class="col"><div class="form-check"><input type="checkbox" class="form-check-input filter-button-source storeable-checkbox" id="filter-button-source-${domSafeName}" value="${o}" autocomplete="off" onClick="filtersUpdated()" ${enable ? "checked" : ""}><label class="form-check-label" for="filter-button-source-${domSafeName}">${o}</label></div></div>`);
});
// Create All/None buttons
$("#source-options").append(` <span style="display: inline-block"><button id="filter-button-source-all" type="button" class="btn btn-outline-secondary" onclick="toggleFilterButtons('source', true);">All</button>&nbsp;<button id="filter-button-source-none" type="button" class="btn btn-outline-secondary" onclick="toggleFilterButtons('source', false);">None</button></span>`);
$("#source-options").append($grid);
$("#source-options").append(`<div class="mt-1"><a href="#" onclick="toggleFilterButtons('source', true); return false;">All</a> &nbsp; <a href="#" onclick="toggleFilterButtons('source', false); return false;">None</a></div>`);
}
// Method called when any filter is changed to reload the spots and persist the filter settings.
+1 -1
View File
@@ -13,7 +13,7 @@ function loadStatus() {
$("#web-server-last-page").text(moment.unix(jsonData["webserver"]["last_page_access"]).utc().fromNow());
$("#cleanup-status").text(jsonData["cleanup"]["status"]);
$("#cleanu-last-ran").text(moment.unix(jsonData["cleanup"]["last_ran"]).utc().fromNow());
$("#cleanup-last-ran").text(moment.unix(jsonData["cleanup"]["last_ran"]).utc().fromNow());
jsonData["spot_providers"].forEach(p => {
$("#spot-providers-status-container").append(`