36 changed files with 8535 additions and 8348 deletions
+6
View File
@@ -0,0 +1,6 @@
<?xml version="1.0" encoding="UTF-8"?>
<project version="4">
<component name="Encoding">
<file url="file://$PROJECT_DIR$/datafiles/MUNICIPIOS.csv" charset="ISO-8859-1" />
</component>
</project>
+1
View File
@@ -2,6 +2,7 @@
<module type="PYTHON_MODULE" version="4"> <module type="PYTHON_MODULE" version="4">
<component name="NewModuleRootManager"> <component name="NewModuleRootManager">
<content url="file://$MODULE_DIR$"> <content url="file://$MODULE_DIR$">
<sourceFolder url="file://$MODULE_DIR$" isTestSource="false" />
<excludeFolder url="file://$MODULE_DIR$/.venv" /> <excludeFolder url="file://$MODULE_DIR$/.venv" />
<excludeFolder url="file://$MODULE_DIR$/webassets/vendor" /> <excludeFolder url="file://$MODULE_DIR$/webassets/vendor" />
</content> </content>
+10 -4
View File
@@ -19,6 +19,8 @@ Supported data sources include DX Clusters, the Reverse Beacon Network (RBN), th
SOTA, WWFF, GMA, WWBOTA, HEMA, Parks 'n' Peaks, ZLOTA, WOTA, BOTA, LLOTA, WWTOTA, Tiles on the Air, the UK Packet 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. Repeater Network, NG3K, and any site based on the xOTA software by nischu.
Additional Special Interest Groups (SIGs) without their own specific data source include WAB, WAI and DME.
![Screenshot](/images/screenshot2.png) ![Screenshot](/images/screenshot2.png)
![Screenshot](/images/screenshot3.png) ![Screenshot](/images/screenshot3.png)
@@ -93,9 +95,9 @@ Various approaches exist to writing your own client, but in general:
`https://spothole.app/api/v1/spots` once every few minutes. Apply filters if necessary. `https://spothole.app/api/v1/spots` once every few minutes. Apply filters if necessary.
* Call the "options" API to get an idea of which bands, modes etc. the server knows about. You might want to do that * Call the "options" API to get an idea of which bands, modes etc. the server knows about. You might want to do that
first before calling the spots/alerts APIs, to allow you to populate your filters correctly. first before calling the spots/alerts APIs, to allow you to populate your filters correctly.
* Refer to the provided HTML/JS interface for a reference on different approaches. For example, the "map" and "bands" * Refer to the provided HTML/JS interface for a reference on different approaches. For example, the "alerts"/"upcoming"
pages simply query the main spot API on a timer, whereas the main/spots page combines this approach with using the page simply query the main spot API on a timer, whereas the spots, map and bands pages combine this approach with
Server-Sent Events (SSE) endpoint to update live. using the Server-Sent Events (SSE) endpoint to update live.
* Let me know if you get stuck, I'm happy to help. * Let me know if you get stuck, I'm happy to help.
Please don't hammer the API with an unnecessarily high request rate. For example, Spothole only queries the POTA API Please don't hammer the API with an unnecessarily high request rate. For example, Spothole only queries the POTA API
@@ -515,7 +517,11 @@ The same approach as above is also used for alert providers.
As well as being my work, I have also gratefully received feature patches from Steven, M1SDH. As well as being my work, I have also gratefully received feature patches from Steven, M1SDH.
The project contains GeoJSON files for CQ and ITU zones, in the `/datafiles/` directory. These are MIT-licenced and, to The project contains GeoJSON files for CQ and ITU zones, in the `/datafiles/` directory. These are MIT-licenced and, to
my knowledge, created by HA8TKS for his CQ and ITU zone layers for Leaflet. my knowledge, created by HA8TKS for his CQ and ITU zone layers for Leaflet. `/datafiles` also contains a
`MUNICIPIOS.csv` file, from the "Nomenclátor Geográfico de Municipios y Entidades de Población" data set sourced from
[el Centro Nacional de Información Geográfica](https://centrodedescargas.cnig.es/CentroDescargas/home).
`didbase-stations.csv` and the TOTA CSV files were created by me based on publicly available data from GIRO and from
maps of conference centres.
The project contains a set of flag icons generated using the "Noto Color Emoji" font on a Debian system, in the The project contains a set of flag icons generated using the "Noto Color Emoji" font on a Debian system, in the
`/webassets/img/flags/` directory. `/webassets/img/flags/` directory.
+2 -2
View File
@@ -66,8 +66,8 @@ spot-providers:
name: "LLOTA" name: "LLOTA"
enabled: true enabled: true
- class: "WWTOTA" - class: "Towers"
name: "WWTOTA" name: "Towers"
enabled: true enabled: true
- class: "Tiles" - class: "Tiles"
+23 -23
View File
@@ -11,29 +11,29 @@ HAMQTH_PRG = ("Spothole v" + SOFTWARE_VERSION + " operated by " + SERVER_OWNER_C
# Special Interest Groups # Special Interest Groups
SIGS = [ SIGS = [
SIG(name="POTA", description="Parks on the Air", ref_regex=r"[A-Z]{2}\-\d{4,5}"), SIG(name="POTA", comment_names=["POTA"], description="Parks on the Air", ref_regex=r"[A-Z]{2}\-\d{4,5}"),
SIG(name="SOTA", description="Summits on the Air", ref_regex=r"[A-Z0-9]{1,3}\/[A-Z]{2}\-\d{3}"), SIG(name="SOTA", comment_names=["SOTA"], description="Summits on the Air", ref_regex=r"[A-Z0-9]{1,3}\/[A-Z]{2}\-\d{3}"),
SIG(name="WWFF", description="World Wide Flora & Fauna", ref_regex=r"[A-Z0-9]{1,3}FF\-\d{4}"), SIG(name="WWFF", comment_names=["WWFF"], description="World Wide Flora & Fauna", ref_regex=r"[A-Z0-9]{1,3}FF\-\d{4}"),
SIG(name="GMA", description="Global Mountain Activity", ref_regex=r"[A-Z0-9]{1,3}\/[A-Z]{2}\-\d{3}"), SIG(name="GMA", comment_names=["GMA"], description="Global Mountain Activity", ref_regex=r"[A-Z0-9]{1,3}\/[A-Z]{2}\-\d{3}"),
SIG(name="WWBOTA", description="Worldwide Bunkers on the Air", ref_regex=r"B\/[A-Z0-9]{1,3}\-\d{3,4}"), SIG(name="WWBOTA", comment_names=["WWBOTA", "BOTA"], description="Worldwide Bunkers on the Air", ref_regex=r"B\/[A-Z0-9]{1,3}\-\d{3,4}"),
SIG(name="HEMA", description="HuMPs Excluding Marilyns Award", ref_regex=r"[A-Z0-9]{1,3}\/[A-Z]{3}\-\d{3}"), SIG(name="HEMA", comment_names=["HEMA"], description="HuMPs Excluding Marilyns Award", ref_regex=r"[A-Z0-9]{1,3}\/[A-Z]{3}\-\d{3}"),
SIG(name="IOTA", description="Islands on the Air", ref_regex=r"[A-Z]{2}\-\d{3}"), SIG(name="IOTA", comment_names=["IOTA"], description="Islands on the Air", ref_regex=r"[A-Z]{2}\-\d{3}"),
SIG(name="MOTA", description="Mills on the Air", ref_regex=r"X\d{4,6}"), SIG(name="MOTA", comment_names=["MOTA"], description="Mills on the Air", ref_regex=r"X\d{4,6}"),
SIG(name="ARLHS", description="Amateur Radio Lighthouse Society", ref_regex=r"[A-Z]{3}\-\d{3,4}"), SIG(name="ARLHS", comment_names=["ARLHS"], description="Amateur Radio Lighthouse Society", ref_regex=r"[A-Z]{3}\-\d{3,4}"),
SIG(name="ILLW", description="International Lighthouse & Lightship Weekend", ref_regex=r"[A-Z]{2}\d{4}"), SIG(name="ILLW", comment_names=["ILLW"], description="International Lighthouse & Lightship Weekend", ref_regex=r"[A-Z]{2}\d{4}"),
SIG(name="SIOTA", description="Silos on the Air", ref_regex=r"[A-Z]{2}\-[A-Z]{3}\d"), SIG(name="SiOTA", comment_names=["SIOTA"], description="Silos on the Air", ref_regex=r"[A-Z]{2}\-[A-Z]{3}\d"),
SIG(name="WCA", description="World Castles Award", ref_regex=r"[A-Z0-9]{1,3}\-\d{5}"), SIG(name="WCA", comment_names=["WCA"], description="World Castles Award", ref_regex=r"[A-Z0-9]{1,3}\-\d{5}"),
SIG(name="ZLOTA", description="New Zealand on the Air", ref_regex=r"ZL[A-Z]/[A-Z]{2}\-\d{3,4}"), SIG(name="ZLOTA", comment_names=["ZLOTA"], description="New Zealand on the Air", ref_regex=r"ZL[A-Z]/[A-Z]{2}\-\d{3,4}"),
SIG(name="WOTA", description="Wainwrights on the Air", ref_regex=r"[A-Z]{3}-[0-9]{2}"), SIG(name="WOTA", comment_names=["WOTA"], description="Wainwrights on the Air", ref_regex=r"[A-Z]{3}-[0-9]{2}"),
SIG(name="BOTA", description="Beaches on the Air"), SIG(name="BOTA", comment_names=[], description="Beaches on the Air"),
SIG(name="KRMNPA", description="Keith Roget Memorial National Parks Award"), SIG(name="KRMNPA", comment_names=["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="LLOTA", comment_names=["LLOTA"], description="Lagos y Lagunas on the Air", ref_regex=r"LL[A-Z]{2}\-\d{4}"),
SIG(name="WWTOTA", description="Towers on the Air", ref_regex=r"[A-Z]{2}R\-\d{4}"), SIG(name="Towers", comment_names=["TOTA"], 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="Tiles", comment_names=[], 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="WAB", comment_names=["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="WAI", comment_names=["WAI"], description="Worked All Ireland", ref_regex=r"[A-Z][0-9]{2}"),
SIG(name="DME", description="Diplomas de Municipios Españoles", ref_regex=r"\d{4,5}"), SIG(name="DME", comment_names=["DME"], description="Diplomas de Municipios Españoles", ref_regex=r"\d{4,5}"),
SIG(name="TOTA", description="Toilets on the Air", ref_regex=r"T\-[0-9]{2}") SIG(name="Toilets", comment_names=[], description="Toilets on the Air", ref_regex=r"T\-[0-9]{2}")
] ]
# Modes. Note "DIGI" and "DIGITAL" are also supported but are normalised into "DATA". # Modes. Note "DIGI" and "DIGITAL" are also supported but are normalised into "DATA".
+21 -11
View File
@@ -7,8 +7,10 @@ from core.cache_utils import SEMI_STATIC_URL_DATA_CACHE
from core.constants import SIGS, HTTP_HEADERS from core.constants import SIGS, HTTP_HEADERS
from core.geo_utils import wab_wai_square_to_lat_lon from core.geo_utils import wab_wai_square_to_lat_lon
with open("datafiles/dme-geodata.csv", encoding="utf-8") as _f: # Load Spanish municipality data for the DME programme. There's no convenient lookup API for this, so we embed the data
_DME_INDEX = {row["dme"]: row for row in csv.DictReader(_f)} # file in Spothole and load it on startup.
with open("datafiles/MUNICIPIOS.csv", encoding="latin-1") as _f:
_DME_INDEX = {row["COD_INE"][:5]: row for row in csv.DictReader(_f, delimiter=";")}
def get_ref_regex_for_sig(sig): def get_ref_regex_for_sig(sig):
@@ -20,6 +22,16 @@ def get_ref_regex_for_sig(sig):
return None return None
def get_sig_name_from_comment_name(sig):
"""Utility function to get the name of a SIG from its "comment name". Generally these will be the same but there are
some cases (e.g. is "TOTA" Towers, Tiles or Toilets?) where we need to transform one to the other."""
for s in SIGS:
if any(n.upper() == sig.upper() for n in s.comment_names):
return s.name
return None
def populate_sig_ref_info(sig_ref): def populate_sig_ref_info(sig_ref):
"""Look up details of a SIG reference (e.g. POTA park) such as name, lat/lon, and grid. Takes in a sig_ref object which """Look up details of a SIG reference (e.g. POTA park) such as name, lat/lon, and grid. Takes in a sig_ref object which
must at minimum have a "sig" and an "id". The rest of the object will be populated and returned. must at minimum have a "sig" and an "id". The rest of the object will be populated and returned.
@@ -192,11 +204,12 @@ def populate_sig_ref_info(sig_ref):
except: except:
logging.debug("Invalid lat/lon received for reference") logging.debug("Invalid lat/lon received for reference")
elif sig.upper() == "DME": elif sig.upper() == "DME":
row = _DME_INDEX.get(ref_id) # Zero-pad to 5 digits to match our source data
row = _DME_INDEX.get(ref_id.zfill(5))
if row: if row:
sig_ref.name = row["municipio"] + ", " + row["provincia"] sig_ref.name = row["NOMBRE_ACTUAL"] + ", " + row["PROVINCIA"]
sig_ref.latitude = float(row["lat"]) if row.get("lat") else None sig_ref.latitude = float(row["LATITUD_ETRS89_REGCAN95"].replace(",", ".")) if row.get("LATITUD_ETRS89_REGCAN95") else None
sig_ref.longitude = float(row["lon"]) if row.get("lon") else None sig_ref.longitude = float(row["LONGITUD_ETRS89_REGCAN95"].replace(",", ".")) if row.get("LONGITUD_ETRS89_REGCAN95") else None
if sig_ref.latitude and sig_ref.longitude: if sig_ref.latitude and sig_ref.longitude:
try: try:
sig_ref.grid = latlong_to_locator(sig_ref.latitude, sig_ref.longitude, 6) sig_ref.grid = latlong_to_locator(sig_ref.latitude, sig_ref.longitude, 6)
@@ -207,8 +220,5 @@ def populate_sig_ref_info(sig_ref):
return sig_ref return sig_ref
# Regex matching any SIG # Regex matching any SIG's "comment name", i.e. how it may be referred to in spot comments
ANY_SIG_REGEX = r"(" + r"|".join(list(map(lambda p: p.name, SIGS))) + r")" ANY_SIG_REGEX = r"(" + r"|".join(n for s in SIGS for n in s.comment_names) + r")"
# Regex matching any SIG reference
ANY_XOTA_SIG_REF_REGEX = r"[\w\/]+\-\d+"
+8 -5
View File
@@ -1,8 +1,11 @@
def serialize_everything(obj): import simplejson
"""Convert objects to serialisable things. Used by JSON serialiser as a default when it encounters unserializable things.
Just converts objects to dict. Try to avoid doing anything clever here when serialising spots, because we also need
to receive spots without complex handling.""" def safe_json_dumps(obj):
return obj.__dict__ """Safe version of json.dumps that also converts objects to dicts so they can be output, and ignores NaN floats
which are invalid in JSON."""
return simplejson.dumps(obj, ensure_ascii=False, ignore_nan=True, default=lambda o: o.__dict__)
def empty_queue(q): def empty_queue(q):
+13 -5
View File
@@ -1,13 +1,21 @@
from dataclasses import dataclass from dataclasses import dataclass, field
@dataclass @dataclass
class SIG: class SIG:
"""Data class that defines a Special Interest Group.""" """Data class that defines a Special Interest Group. Each contains a name and a longer form description.
They also contain comment_names which attempts to separate out the way people might refer to it in
cluster comments from how it is referred to in the UI & API. (For example, "TOTA" in cluster spot comments
almost always means Towers on the Air, but no single programme is referred to in the UI as "TOTA" as
it's ambiguous between Towers, Toilets and Tiles. And while Beaches got the name "BOTA" first, "BOTA" spots
are much more likely to be bunkers.) Finally, there is a ref_regex which provides a regular expression to
match what references (such as parks and summits) look like for that programme."""
# SIG name, e.g. "POTA" # SIG name as used in the UI and API, e.g. "Towers"
name: str name: str
# Description, e.g. "Parks on the Air" # Description, e.g. "Towers on the Air"
description: str description: str
# SIG names as they might appear in cluster spot comments, e.g. ["TOTA"]
comment_names: list[str] = field(default_factory=list)
# Regex matcher for references, e.g. for POTA r"[A-Z]{2}\-\d+". # Regex matcher for references, e.g. for POTA r"[A-Z]{2}\-\d+".
ref_regex: str = None ref_regex: str | None = None
+3 -3
View File
@@ -14,7 +14,7 @@ from core.constants import MODE_ALIASES, PROPAGATION_MODES
from core.geo_utils import lat_lon_to_cq_zone, lat_lon_to_itu_zone 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, \ from core.lookup_helper import lookup_helper, infer_band_from_freq, infer_mode_from_comment, \
infer_mode_from_frequency, infer_mode_type_from_mode infer_mode_from_frequency, infer_mode_type_from_mode
from core.sig_utils import populate_sig_ref_info, ANY_SIG_REGEX, get_ref_regex_for_sig from core.sig_utils import populate_sig_ref_info, ANY_SIG_REGEX, get_ref_regex_for_sig, get_sig_name_from_comment_name
from data.sig_ref import SIGRef from data.sig_ref import SIGRef
@@ -255,7 +255,7 @@ class Spot:
for sig_match in sig_matches: for sig_match in sig_matches:
# First of all, if we haven't got a SIG for this spot set yet, now we have. This covers things like cluster # First of all, if we haven't got a SIG for this spot set yet, now we have. This covers things like cluster
# spots where the comment is just "POTA". # spots where the comment is just "POTA".
found_sig = sig_match.group(2).upper() found_sig = get_sig_name_from_comment_name(sig_match.group(2))
if not self.sig: if not self.sig:
self.sig = found_sig self.sig = found_sig
@@ -312,7 +312,7 @@ class Spot:
self.propagation_mode = PROPAGATION_MODES[mode_tag] self.propagation_mode = PROPAGATION_MODES[mode_tag]
else: else:
self.propagation_mode = mode_tag self.propagation_mode = mode_tag
logging.info("Seen a new propagation mode tag not yet in the system: {}", mode_tag) logging.info("Seen a new propagation mode tag not yet in the system: %s", mode_tag)
# DX Grid to lat/lon and vice versa in case one is missing # DX Grid to lat/lon and vice versa in case one is missing
if self.dx_grid and not self.dx_latitude: if self.dx_grid and not self.dx_latitude:
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
+1
View File
@@ -17,3 +17,4 @@ websocket-client~=1.8.0
tornado~=6.4.2 tornado~=6.4.2
tornado_eventsource~=3.0.0 tornado_eventsource~=3.0.0
geopandas~=0.13.2 geopandas~=0.13.2
simplejson~=4.1.1
+13 -21
View File
@@ -14,7 +14,7 @@ from core.constants import UNKNOWN_BAND
from core.lookup_helper import infer_band_from_freq from core.lookup_helper import infer_band_from_freq
from core.prometheus_metrics_handler import api_requests_counter from core.prometheus_metrics_handler import api_requests_counter
from core.sig_utils import get_ref_regex_for_sig from core.sig_utils import get_ref_regex_for_sig
from core.utils import serialize_everything from core.utils import safe_json_dumps
from data.sig_ref import SIGRef from data.sig_ref import SIGRef
from data.spot import Spot from data.spot import Spot
@@ -42,8 +42,7 @@ class APISpotHandler(tornado.web.RequestHandler):
# Reject if not allowed # Reject if not allowed
if not ALLOW_SPOTTING: if not ALLOW_SPOTTING:
self.set_status(401) self.set_status(401)
self.write(json.dumps("Error - this server does not allow new spots to be added via the API.", self.write(safe_json_dumps("Error - this server does not allow new spots to be added via the API."))
default=serialize_everything))
self.set_header("Cache-Control", "no-store") self.set_header("Cache-Control", "no-store")
self.set_header("Content-Type", "application/json") self.set_header("Content-Type", "application/json")
return return
@@ -51,8 +50,7 @@ class APISpotHandler(tornado.web.RequestHandler):
# Reject if format not json # Reject if format not json
if not self.request.headers.get('Content-Type', '').startswith("application/json"): if not self.request.headers.get('Content-Type', '').startswith("application/json"):
self.set_status(415) self.set_status(415)
self.write( self.write(safe_json_dumps("Error - request Content-Type must be application/json"))
json.dumps("Error - request Content-Type must be application/json", default=serialize_everything))
self.set_header("Cache-Control", "no-store") self.set_header("Cache-Control", "no-store")
self.set_header("Content-Type", "application/json") self.set_header("Content-Type", "application/json")
return return
@@ -61,7 +59,7 @@ class APISpotHandler(tornado.web.RequestHandler):
post_data = self.request.body post_data = self.request.body
if not post_data: if not post_data:
self.set_status(422) self.set_status(422)
self.write(json.dumps("Error - request body is empty", default=serialize_everything)) self.write(safe_json_dumps("Error - request body is empty"))
self.set_header("Cache-Control", "no-store") self.set_header("Cache-Control", "no-store")
self.set_header("Content-Type", "application/json") self.set_header("Content-Type", "application/json")
return return
@@ -81,8 +79,7 @@ class APISpotHandler(tornado.web.RequestHandler):
# Reject if no timestamp, frequency, dx_call or de_call # Reject if no timestamp, frequency, dx_call or de_call
if not spot.time or not spot.dx_call or not spot.freq or not spot.de_call: if not spot.time or not spot.dx_call or not spot.freq or not spot.de_call:
self.set_status(422) self.set_status(422)
self.write(json.dumps("Error - 'time', 'dx_call', 'freq' and 'de_call' must be provided as a minimum.", self.write(safe_json_dumps("Error - 'time', 'dx_call', 'freq' and 'de_call' must be provided as a minimum."))
default=serialize_everything))
self.set_header("Cache-Control", "no-store") self.set_header("Cache-Control", "no-store")
self.set_header("Content-Type", "application/json") self.set_header("Content-Type", "application/json")
return return
@@ -90,15 +87,13 @@ class APISpotHandler(tornado.web.RequestHandler):
# Reject invalid-looking callsigns # Reject invalid-looking callsigns
if not re.match(r"^[A-Za-z0-9/\-]*$", spot.dx_call): if not re.match(r"^[A-Za-z0-9/\-]*$", spot.dx_call):
self.set_status(422) self.set_status(422)
self.write(json.dumps("Error - '" + spot.dx_call + "' does not look like a valid callsign.", self.write(safe_json_dumps("Error - '" + spot.dx_call + "' does not look like a valid callsign."))
default=serialize_everything))
self.set_header("Cache-Control", "no-store") self.set_header("Cache-Control", "no-store")
self.set_header("Content-Type", "application/json") self.set_header("Content-Type", "application/json")
return return
if not re.match(r"^[A-Za-z0-9/\-]*$", spot.de_call): if not re.match(r"^[A-Za-z0-9/\-]*$", spot.de_call):
self.set_status(422) self.set_status(422)
self.write(json.dumps("Error - '" + spot.de_call + "' does not look like a valid callsign.", self.write(safe_json_dumps("Error - '" + spot.de_call + "' does not look like a valid callsign."))
default=serialize_everything))
self.set_header("Cache-Control", "no-store") self.set_header("Cache-Control", "no-store")
self.set_header("Content-Type", "application/json") self.set_header("Content-Type", "application/json")
return return
@@ -106,8 +101,7 @@ class APISpotHandler(tornado.web.RequestHandler):
# Reject if frequency not in a known band # Reject if frequency not in a known band
if infer_band_from_freq(spot.freq) == UNKNOWN_BAND: if infer_band_from_freq(spot.freq) == UNKNOWN_BAND:
self.set_status(422) self.set_status(422)
self.write(json.dumps("Error - Frequency of " + str(spot.freq / 1000.0) + "kHz is not in a known band.", self.write(safe_json_dumps("Error - Frequency of " + str(spot.freq / 1000.0) + "kHz is not in a known band."))
default=serialize_everything))
self.set_header("Cache-Control", "no-store") self.set_header("Cache-Control", "no-store")
self.set_header("Content-Type", "application/json") self.set_header("Content-Type", "application/json")
return return
@@ -117,8 +111,7 @@ class APISpotHandler(tornado.web.RequestHandler):
r"^([A-R]{2}[0-9]{2}[A-X]{2}[0-9]{2}[A-X]{2}|[A-R]{2}[0-9]{2}[A-X]{2}[0-9]{2}|[A-R]{2}[0-9]{2}[A-X]{2}|[A-R]{2}[0-9]{2})$", r"^([A-R]{2}[0-9]{2}[A-X]{2}[0-9]{2}[A-X]{2}|[A-R]{2}[0-9]{2}[A-X]{2}[0-9]{2}|[A-R]{2}[0-9]{2}[A-X]{2}|[A-R]{2}[0-9]{2})$",
spot.dx_grid.upper()): spot.dx_grid.upper()):
self.set_status(422) self.set_status(422)
self.write(json.dumps("Error - '" + spot.dx_grid + "' does not look like a valid Maidenhead grid.", self.write(safe_json_dumps("Error - '" + spot.dx_grid + "' does not look like a valid Maidenhead grid."))
default=serialize_everything))
self.set_header("Cache-Control", "no-store") self.set_header("Cache-Control", "no-store")
self.set_header("Content-Type", "application/json") self.set_header("Content-Type", "application/json")
return return
@@ -127,9 +120,8 @@ class APISpotHandler(tornado.web.RequestHandler):
if spot.sig and spot.sig_refs and len(spot.sig_refs) > 0 and spot.sig_refs[0].id and get_ref_regex_for_sig( if spot.sig and spot.sig_refs and len(spot.sig_refs) > 0 and spot.sig_refs[0].id and get_ref_regex_for_sig(
spot.sig) and not re.match(get_ref_regex_for_sig(spot.sig), spot.sig_refs[0].id): spot.sig) and not re.match(get_ref_regex_for_sig(spot.sig), spot.sig_refs[0].id):
self.set_status(422) self.set_status(422)
self.write(json.dumps( self.write(safe_json_dumps(
"Error - '" + spot.sig_refs[0].id + "' does not look like a valid reference for " + spot.sig + ".", "Error - '" + spot.sig_refs[0].id + "' does not look like a valid reference for " + spot.sig + "."))
default=serialize_everything))
self.set_header("Cache-Control", "no-store") self.set_header("Cache-Control", "no-store")
self.set_header("Content-Type", "application/json") self.set_header("Content-Type", "application/json")
return return
@@ -139,14 +131,14 @@ class APISpotHandler(tornado.web.RequestHandler):
spot.infer_missing() spot.infer_missing()
self._spots.add(spot.id, spot, expire=MAX_SPOT_AGE) self._spots.add(spot.id, spot, expire=MAX_SPOT_AGE)
self.write(json.dumps("OK", default=serialize_everything)) self.write(safe_json_dumps("OK"))
self.set_status(201) self.set_status(201)
self.set_header("Cache-Control", "no-store") self.set_header("Cache-Control", "no-store")
self.set_header("Content-Type", "application/json") self.set_header("Content-Type", "application/json")
except Exception as e: except Exception as e:
logging.error(e) logging.error(e)
self.write(json.dumps("Error - an internal server error occurred.", default=serialize_everything)) self.write(safe_json_dumps("Error - an internal server error occurred."))
self.set_status(500) self.set_status(500)
self.set_header("Cache-Control", "no-store") self.set_header("Cache-Control", "no-store")
self.set_header("Content-Type", "application/json") self.set_header("Content-Type", "application/json")
+5 -6
View File
@@ -1,5 +1,4 @@
import copy import copy
import json
import logging import logging
from datetime import datetime from datetime import datetime
from queue import Queue from queue import Queue
@@ -12,7 +11,7 @@ from tornado import httputil
from tornado.web import Application from tornado.web import Application
from core.prometheus_metrics_handler import api_requests_counter from core.prometheus_metrics_handler import api_requests_counter
from core.utils import serialize_everything, empty_queue from core.utils import safe_json_dumps, empty_queue
from data.lookup_credentials import extract_credentials from data.lookup_credentials import extract_credentials
SSE_HANDLER_MAX_QUEUE_SIZE = 100 SSE_HANDLER_MAX_QUEUE_SIZE = 100
@@ -57,15 +56,15 @@ class APIAlertsHandler(tornado.web.RequestHandler):
data = get_alert_list_with_filters(self._alerts, query_params) data = get_alert_list_with_filters(self._alerts, query_params)
if credentials: if credentials:
data = self._enrich(data, credentials) data = self._enrich(data, credentials)
self.write(json.dumps(data, default=serialize_everything)) self.write(safe_json_dumps(data))
self.set_status(200) self.set_status(200)
except ValueError as e: except ValueError as e:
logging.error(e) logging.error(e)
self.write(json.dumps("Bad request - " + str(e), default=serialize_everything)) self.write(safe_json_dumps("Bad request - " + str(e)))
self.set_status(400) self.set_status(400)
except Exception as e: except Exception as e:
logging.error(e) logging.error(e)
self.write(json.dumps("Error - an internal server error occurred.", default=serialize_everything)) self.write(safe_json_dumps("Error - an internal server error occurred."))
self.set_status(500) self.set_status(500)
self.set_header("Cache-Control", "no-store") self.set_header("Cache-Control", "no-store")
self.set_header("Content-Type", "application/json") self.set_header("Content-Type", "application/json")
@@ -150,7 +149,7 @@ class APIAlertsStreamHandler(tornado_eventsource.handler.EventSourceHandler):
if self._credentials: if self._credentials:
alert = copy.deepcopy(alert) alert = copy.deepcopy(alert)
alert.infer_missing(self._credentials) alert.infer_missing(self._credentials)
self.write_message(msg=json.dumps(alert, default=serialize_everything)) self.write_message(msg=safe_json_dumps(alert))
else: else:
# Send a keepalive comment if the queue was empty # Send a keepalive comment if the queue was empty
+15 -19
View File
@@ -1,4 +1,3 @@
import json
import logging import logging
import re import re
from datetime import datetime from datetime import datetime
@@ -13,7 +12,7 @@ from core.constants import SIGS
from core.geo_utils import lat_lon_for_grid_sw_corner_plus_size, lat_lon_to_cq_zone, lat_lon_to_itu_zone from core.geo_utils import lat_lon_for_grid_sw_corner_plus_size, lat_lon_to_cq_zone, lat_lon_to_itu_zone
from core.prometheus_metrics_handler import api_requests_counter 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.sig_utils import get_ref_regex_for_sig, populate_sig_ref_info
from core.utils import serialize_everything from core.utils import safe_json_dumps
from data.lookup_credentials import extract_credentials from data.lookup_credentials import extract_credentials
from data.sig_ref import SIGRef from data.sig_ref import SIGRef
from data.spot import Spot from data.spot import Spot
@@ -65,19 +64,18 @@ class APILookupCallHandler(tornado.web.RequestHandler):
"longitude": fake_spot.dx_longitude, "longitude": fake_spot.dx_longitude,
"location_source": fake_spot.dx_location_source "location_source": fake_spot.dx_location_source
} }
self.write(json.dumps(data, default=serialize_everything)) self.write(safe_json_dumps(data))
else: else:
self.write(json.dumps("Error - '" + call + "' does not look like a valid callsign.", self.write(safe_json_dumps("Error - '" + call + "' does not look like a valid callsign."))
default=serialize_everything))
self.set_status(422) self.set_status(422)
else: else:
self.write(json.dumps("Error - call must be provided", default=serialize_everything)) self.write(safe_json_dumps("Error - call must be provided"))
self.set_status(422) self.set_status(422)
except Exception as e: except Exception as e:
logging.error(e) logging.error(e)
self.write(json.dumps("Error - an internal server error occurred.", default=serialize_everything)) self.write(safe_json_dumps("Error - an internal server error occurred."))
self.set_status(500) self.set_status(500)
self.set_header("Cache-Control", "no-store") self.set_header("Cache-Control", "no-store")
@@ -111,27 +109,25 @@ class APILookupSIGRefHandler(tornado.web.RequestHandler):
if "sig" in query_params.keys() and "id" in query_params.keys(): if "sig" in query_params.keys() and "id" in query_params.keys():
sig = str(query_params.get("sig")).upper() sig = str(query_params.get("sig")).upper()
ref_id = str(query_params.get("id")).upper() ref_id = str(query_params.get("id")).upper()
if sig in list(map(lambda p: p.name, SIGS)): if sig in list(map(lambda p: p.name.upper(), SIGS)):
if not get_ref_regex_for_sig(sig) or re.match(get_ref_regex_for_sig(sig), ref_id): if not get_ref_regex_for_sig(sig) or re.match(get_ref_regex_for_sig(sig), ref_id):
data = populate_sig_ref_info(SIGRef(id=ref_id, sig=sig)) data = populate_sig_ref_info(SIGRef(id=ref_id, sig=sig))
self.write(json.dumps(data, default=serialize_everything)) self.write(safe_json_dumps(data))
else: else:
self.write( self.write(safe_json_dumps(
json.dumps( "Error - '" + ref_id + "' does not look like a valid reference ID for " + sig + "."))
"Error - '" + ref_id + "' does not look like a valid reference ID for " + sig + ".",
default=serialize_everything))
self.set_status(422) self.set_status(422)
else: else:
self.write(json.dumps("Error - sig '" + sig + "' is not known.", default=serialize_everything)) self.write(safe_json_dumps("Error - sig '" + sig + "' is not known."))
self.set_status(422) self.set_status(422)
else: else:
self.write(json.dumps("Error - sig and id must be provided", default=serialize_everything)) self.write(safe_json_dumps("Error - sig and id must be provided"))
self.set_status(422) self.set_status(422)
except Exception as e: except Exception as e:
logging.error(e) logging.error(e)
self.write(json.dumps("Error - an internal server error occurred.", default=serialize_everything)) self.write(safe_json_dumps("Error - an internal server error occurred."))
self.set_status(500) self.set_status(500)
self.set_header("Cache-Control", "no-store") self.set_header("Cache-Control", "no-store")
@@ -185,15 +181,15 @@ class APILookupGridHandler(tornado.web.RequestHandler):
"latitude": lat + lat_cell_size, "latitude": lat + lat_cell_size,
"longitude": lon + lon_cell_size, "longitude": lon + lon_cell_size,
}} }}
self.write(json.dumps(response, default=serialize_everything)) self.write(safe_json_dumps(response))
else: else:
self.write(json.dumps("Error - grid must be provided", default=serialize_everything)) self.write(safe_json_dumps("Error - grid must be provided"))
self.set_status(422) self.set_status(422)
except Exception as e: except Exception as e:
logging.error(e) logging.error(e)
self.write(json.dumps("Error - an internal server error occurred.", default=serialize_everything)) self.write(safe_json_dumps("Error - an internal server error occurred."))
self.set_status(500) self.set_status(500)
self.set_header("Cache-Control", "no-store") self.set_header("Cache-Control", "no-store")
+2 -3
View File
@@ -1,4 +1,3 @@
import json
from datetime import datetime from datetime import datetime
from typing import Any from typing import Any
@@ -10,7 +9,7 @@ from tornado.web import Application
from core.config import MAX_SPOT_AGE, ALLOW_SPOTTING from core.config import MAX_SPOT_AGE, ALLOW_SPOTTING
from core.constants import BANDS, ALL_MODES, MODE_TYPES, SIGS, CONTINENTS, PROPAGATION_MODES from core.constants import BANDS, ALL_MODES, MODE_TYPES, SIGS, CONTINENTS, PROPAGATION_MODES
from core.prometheus_metrics_handler import api_requests_counter from core.prometheus_metrics_handler import api_requests_counter
from core.utils import serialize_everything from core.utils import safe_json_dumps
class APIOptionsHandler(tornado.web.RequestHandler): class APIOptionsHandler(tornado.web.RequestHandler):
@@ -50,7 +49,7 @@ class APIOptionsHandler(tornado.web.RequestHandler):
if ALLOW_SPOTTING: if ALLOW_SPOTTING:
options["spot_sources"].append("API") options["spot_sources"].append("API")
self.write(json.dumps(options, default=serialize_everything)) self.write(safe_json_dumps(options))
self.set_status(200) self.set_status(200)
self.set_header("Cache-Control", "no-store") self.set_header("Cache-Control", "no-store")
self.set_header("Content-Type", "application/json") self.set_header("Content-Type", "application/json")
+5 -6
View File
@@ -1,5 +1,4 @@
import copy import copy
import json
import logging import logging
from datetime import datetime, timedelta from datetime import datetime, timedelta
from queue import Queue from queue import Queue
@@ -12,7 +11,7 @@ from tornado import httputil
from tornado.web import Application from tornado.web import Application
from core.prometheus_metrics_handler import api_requests_counter from core.prometheus_metrics_handler import api_requests_counter
from core.utils import serialize_everything, empty_queue from core.utils import safe_json_dumps, empty_queue
from data.lookup_credentials import extract_credentials from data.lookup_credentials import extract_credentials
SSE_HANDLER_MAX_QUEUE_SIZE = 1000 SSE_HANDLER_MAX_QUEUE_SIZE = 1000
@@ -57,15 +56,15 @@ class APISpotsHandler(tornado.web.RequestHandler):
data = get_spot_list_with_filters(self._spots, query_params) data = get_spot_list_with_filters(self._spots, query_params)
if credentials: if credentials:
data = self._enrich(data, credentials) data = self._enrich(data, credentials)
self.write(json.dumps(data, default=serialize_everything)) self.write(safe_json_dumps(data))
self.set_status(200) self.set_status(200)
except ValueError as e: except ValueError as e:
logging.error(e) logging.error(e)
self.write(json.dumps("Bad request - " + str(e), default=serialize_everything)) self.write(safe_json_dumps("Bad request - " + str(e)))
self.set_status(400) self.set_status(400)
except Exception as e: except Exception as e:
logging.error(e) logging.error(e)
self.write(json.dumps("Error - an internal server error occurred.", default=serialize_everything)) self.write(safe_json_dumps("Error - an internal server error occurred."))
self.set_status(500) self.set_status(500)
self.set_header("Cache-Control", "no-store") self.set_header("Cache-Control", "no-store")
self.set_header("Content-Type", "application/json") self.set_header("Content-Type", "application/json")
@@ -152,7 +151,7 @@ class APISpotsStreamHandler(tornado_eventsource.handler.EventSourceHandler):
if self._credentials: if self._credentials:
spot = copy.deepcopy(spot) spot = copy.deepcopy(spot)
spot.infer_missing(self._credentials) spot.infer_missing(self._credentials)
self.write_message(msg=json.dumps(spot, default=serialize_everything)) self.write_message(msg=safe_json_dumps(spot))
else: else:
# Send a keepalive comment if the queue was empty # Send a keepalive comment if the queue was empty
+2 -3
View File
@@ -1,4 +1,3 @@
import json
from datetime import datetime from datetime import datetime
from typing import Any from typing import Any
@@ -8,7 +7,7 @@ from tornado import httputil
from tornado.web import Application from tornado.web import Application
from core.prometheus_metrics_handler import api_requests_counter from core.prometheus_metrics_handler import api_requests_counter
from core.utils import serialize_everything from core.utils import safe_json_dumps
class APIStatusHandler(tornado.web.RequestHandler): class APIStatusHandler(tornado.web.RequestHandler):
@@ -30,7 +29,7 @@ class APIStatusHandler(tornado.web.RequestHandler):
self._web_server_metrics["status"] = "OK" self._web_server_metrics["status"] = "OK"
api_requests_counter.inc() api_requests_counter.inc()
self.write(json.dumps(self._status_data, default=serialize_everything)) self.write(safe_json_dumps(self._status_data))
self.set_status(200) self.set_status(200)
self.set_header("Cache-Control", "no-store") self.set_header("Cache-Control", "no-store")
self.set_header("Content-Type", "application/json") self.set_header("Content-Type", "application/json")
+13 -9
View File
@@ -14,9 +14,9 @@ class GMA(HTTPSpotProvider):
"""Spot provider for General Mountain Activity""" """Spot provider for General Mountain Activity"""
POLL_INTERVAL_SEC = 120 POLL_INTERVAL_SEC = 120
SPOTS_URL = "https://www.cqgma.org/api/spots/25/" SPOTS_URL = "https://www.gma.rocks/api/spots/25/"
# GMA spots don't contain the details of the programme they are for, we need a separate lookup for that # GMA spots don't contain the details of the programme they are for, we need a separate lookup for that
REF_INFO_URL_ROOT = "https://www.cqgma.org/api/ref/?" REF_INFO_URL_ROOT = "https://www.gma.rocks/api/ref/?"
def __init__(self, provider_config): def __init__(self, provider_config):
# Ensure there is an API key in our config, and set up the query URL using it. If no key is provided, # Ensure there is an API key in our config, and set up the query URL using it. If no key is provided,
@@ -37,19 +37,21 @@ class GMA(HTTPSpotProvider):
spot = Spot(source=self.name, spot = Spot(source=self.name,
dx_call=source_spot["ACTIVATOR"].upper(), dx_call=source_spot["ACTIVATOR"].upper(),
de_call=source_spot["SPOTTER"].upper(), de_call=source_spot["SPOTTER"].upper(),
freq=float(source_spot["QRG"]) * 1000 if (source_spot["QRG"] != "") else None, # Seen GMA spots with no frequency or with "QRT" in this field
# Seen GMA spots with no frequency freq=float(source_spot["QRG"]) * 1000 if (
mode=source_spot["MODE"].upper() if "<>" not in source_spot["MODE"] else None, source_spot["QRG"] != "" and source_spot["QRG"] != "QRT") else None,
# Filter out some weird mode strings # Filter out some weird mode strings
mode=source_spot["MODE"].upper() if "<>" not in source_spot["MODE"] else None,
comment=source_spot["TEXT"], comment=source_spot["TEXT"],
sig_refs=[SIGRef(id=source_spot["REF"], sig="", name=source_spot["NAME"])], sig_refs=[SIGRef(id=source_spot["REF"], sig="", name=source_spot["NAME"])],
time=datetime.strptime(source_spot["DATE"] + source_spot["TIME"], "%Y%m%d%H%M").replace( time=datetime.strptime(source_spot["DATE"] + source_spot["TIME"], "%Y%m%d%H%M").replace(
tzinfo=pytz.UTC).timestamp(), tzinfo=pytz.UTC).timestamp(),
# Seen GMA spots with no (or empty) lat/lon
dx_latitude=float(source_spot["LAT"]) if ( dx_latitude=float(source_spot["LAT"]) if (
source_spot["LAT"] and source_spot["LAT"] != "") else None, source_spot["LAT"] and source_spot["LAT"] != "") else None,
# Seen GMA spots with no (or empty) lat/lon
dx_longitude=float(source_spot["LON"]) if ( dx_longitude=float(source_spot["LON"]) if (
source_spot["LON"] and source_spot["LON"] != "") else None) source_spot["LON"] and source_spot["LON"] != "") else None,
qrt=source_spot["QRG"] == "QRT")
# GMA doesn't give what programme (SIG) the reference is for until we separately look it up. # GMA doesn't give what programme (SIG) the reference is for until we separately look it up.
if "REF" in source_spot: if "REF" in source_spot:
@@ -63,8 +65,10 @@ class GMA(HTTPSpotProvider):
# spots come through with reftype=POTA or reftype=WWFF. SOTA is harder to figure out because both SOTA # spots come through with reftype=POTA or reftype=WWFF. SOTA is harder to figure out because both SOTA
# and GMA summits come through with reftype=Summit, so we must check for the presence of a "sota" entry # and GMA summits come through with reftype=Summit, so we must check for the presence of a "sota" entry
# to determine if it's a SOTA summit. # to determine if it's a SOTA summit.
if spot.sig_refs and "reftype" in ref_info and ref_info["reftype"] not in ["POTA", "WWFF"] and ( if spot.sig_refs and "reftype" in ref_info and ref_info["reftype"] not in ["POTA",
ref_info["reftype"] != "Summit" or "sota" not in ref_info or ref_info["sota"] == ""): "WWFF"] and (
ref_info["reftype"] != "Summit" or "sota" not in ref_info or ref_info[
"sota"] == ""):
match ref_info["reftype"]: match ref_info["reftype"]:
case "Summit": case "Summit":
spot.sig_refs[0].sig = "GMA" spot.sig_refs[0].sig = "GMA"
+2 -2
View File
@@ -30,8 +30,8 @@ class ParksNPeaks(HTTPSpotProvider):
dx_call=source_spot["actCallsign"].upper(), dx_call=source_spot["actCallsign"].upper(),
de_call=source_spot["actSpoter"].upper() if source_spot["actSpoter"] != "" else None, de_call=source_spot["actSpoter"].upper() if source_spot["actSpoter"] != "" else None,
# typo exists in API # typo exists in API
freq=float(source_spot["actFreq"].replace(",", "")) * 1000000 if ( freq=float(source_spot["actFreq"].replace(",", "").replace("+-", "")
source_spot["actFreq"] != "") else None, .replace("+/-", "").strip()) * 1000000 if (source_spot["actFreq"] != "") else None,
# Seen PNP spots with empty frequency, and with comma-separated thousands digits # Seen PNP spots with empty frequency, and with comma-separated thousands digits
mode=source_spot["actMode"].upper(), mode=source_spot["actMode"].upper(),
comment=source_spot["actComments"], comment=source_spot["actComments"],
@@ -6,7 +6,7 @@ from data.spot import Spot
from spotproviders.http_spot_provider import HTTPSpotProvider from spotproviders.http_spot_provider import HTTPSpotProvider
class WWTOTA(HTTPSpotProvider): class Towers(HTTPSpotProvider):
"""Spot provider for Towers on the Air""" """Spot provider for Towers on the Air"""
POLL_INTERVAL_SEC = 120 POLL_INTERVAL_SEC = 120
+3 -3
View File
@@ -101,9 +101,9 @@
(MOTA), the Amateur Radio Lighthouse Socirty (ARLHS), International Lighthouse Lightship Weekend (ILLW), Silos (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 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 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 (LLOTA), Towers on the Air (WWTOTA), Tiles on the Air, Worked All Britain (WAB), Worked All Ireland (WAI), el
Toilets on the Air (TOTA).</p> Diploma Municipios de España (DME) 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 <p>As of the time of writing in June 2026, 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 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> 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> <h4 class="mt-4">Why can I filter spots by both SIG and Source? Isn't that basically the same thing?</h4>
+1 -1
View File
@@ -76,7 +76,7 @@
</div> </div>
<script src="/js/add-spot.js?v=1782076701"></script> <script src="/js/add-spot.js?v=1783357126"></script>
<script>$(document).ready(function () { <script>$(document).ready(function () {
$("#nav-link-add-spot").addClass("active"); $("#nav-link-add-spot").addClass("active");
}); <!-- highlight active page in nav --></script> }); <!-- highlight active page in nav --></script>
+1 -1
View File
@@ -75,7 +75,7 @@
</div> </div>
<script src="/js/alerts.js?v=1782076701"></script> <script src="/js/alerts.js?v=1783357126"></script>
<script>$(document).ready(function () { <script>$(document).ready(function () {
$("#nav-link-alerts").addClass("active"); $("#nav-link-alerts").addClass("active");
}); <!-- highlight active page in nav --></script> }); <!-- highlight active page in nav --></script>
+3 -5
View File
@@ -3,9 +3,7 @@
<div class="mt-3"> <div class="mt-3">
<div id="settingsButtonRow" class="row mb-3"> <div id="settingsButtonRow" class="row mb-3">
<div class="col-auto me-auto pt-3"> <div class="col-auto me-auto pt-3"></div>
{% module Template("widgets/refresh-timer.html", web_ui_options=web_ui_options) %}
</div>
<div class="col-auto"> <div class="col-auto">
<div class="d-inline-flex gap-1"> <div class="d-inline-flex gap-1">
{% module Template("widgets/filters-display-data-buttons.html", web_ui_options=web_ui_options) %} {% module Template("widgets/filters-display-data-buttons.html", web_ui_options=web_ui_options) %}
@@ -77,8 +75,8 @@
<script> <script>
let spotProvidersEnabledByDefault = {% raw json_encode(web_ui_options["spot-providers-enabled-by-default"]) %}; let spotProvidersEnabledByDefault = {% raw json_encode(web_ui_options["spot-providers-enabled-by-default"]) %};
</script> </script>
<script src="/js/spotsbandsandmap.js?v=1782076701"></script> <script src="/js/spotsbandsandmap.js?v=1783357126"></script>
<script src="/js/bands.js?v=1782076701"></script> <script src="/js/bands.js?v=1783357126"></script>
<script>$(document).ready(function () { <script>$(document).ready(function () {
$("#nav-link-bands").addClass("active"); $("#nav-link-bands").addClass("active");
}); <!-- highlight active page in nav --></script> }); <!-- highlight active page in nav --></script>
+5 -5
View File
@@ -1,6 +1,6 @@
{% extends "skeleton.html" %} {% extends "skeleton.html" %}
{% block head_extra %} {% block head_extra %}
<link rel="stylesheet" href="/css/style.css?v=1782076701" type="text/css"> <link rel="stylesheet" href="/css/style.css?v=1783357126" type="text/css">
<link href="/vendor/css/bootstrap-5.3.8.min.css" rel="stylesheet"> <link href="/vendor/css/bootstrap-5.3.8.min.css" rel="stylesheet">
<link href="/vendor/css/fontawesome-6.7.2.min.css" rel="stylesheet"> <link href="/vendor/css/fontawesome-6.7.2.min.css" rel="stylesheet">
<link href="/vendor/css/solid-6.7.2.min.css" rel="stylesheet"> <link href="/vendor/css/solid-6.7.2.min.css" rel="stylesheet">
@@ -10,10 +10,10 @@
<script src="/vendor/js/bootstrap-5.3.8.bundle.min.js"></script> <script src="/vendor/js/bootstrap-5.3.8.bundle.min.js"></script>
<script src="/vendor/js/tinycolor2-1.6.0.min.js"></script> <script src="/vendor/js/tinycolor2-1.6.0.min.js"></script>
<script src="/js/utils.js?v=1782076701"></script> <script src="/js/utils.js?v=1783357126"></script>
<script src="/js/ui-ham.js?v=1782076701"></script> <script src="/js/ui-ham.js?v=1783357126"></script>
<script src="/js/geo.js?v=1782076701"></script> <script src="/js/geo.js?v=1783357126"></script>
<script src="/js/common.js?v=1782076701"></script> <script src="/js/common.js?v=1783357126"></script>
{% end %} {% end %}
{% block body %} {% block body %}
<div class="container"> <div class="container">
+1 -1
View File
@@ -284,7 +284,7 @@
</div> </div>
<script src="/vendor/js/chart-4.4.9.umd.min.js"></script> <script src="/vendor/js/chart-4.4.9.umd.min.js"></script>
<script src="/js/conditions.js?v=1782076701"></script> <script src="/js/conditions.js?v=1783357126"></script>
<script>$(document).ready(function () { <script>$(document).ready(function () {
$("#nav-link-conditions").addClass("active"); $("#nav-link-conditions").addClass("active");
}); <!-- highlight active page in nav --></script> }); <!-- highlight active page in nav --></script>
+2 -2
View File
@@ -95,8 +95,8 @@
<script> <script>
let spotProvidersEnabledByDefault = {% raw json_encode(web_ui_options["spot-providers-enabled-by-default"]) %}; let spotProvidersEnabledByDefault = {% raw json_encode(web_ui_options["spot-providers-enabled-by-default"]) %};
</script> </script>
<script src="/js/spotsbandsandmap.js?v=1782076701"></script> <script src="/js/spotsbandsandmap.js?v=1783357125"></script>
<script src="/js/map.js?v=1782076701"></script> <script src="/js/map.js?v=1783357125"></script>
<script>$(document).ready(function () { <script>$(document).ready(function () {
$("#nav-link-map").addClass("active"); $("#nav-link-map").addClass("active");
}); <!-- highlight active page in nav --></script> }); <!-- highlight active page in nav --></script>
+2 -2
View File
@@ -116,8 +116,8 @@
<script> <script>
let spotProvidersEnabledByDefault = {% raw json_encode(web_ui_options["spot-providers-enabled-by-default"]) %}; let spotProvidersEnabledByDefault = {% raw json_encode(web_ui_options["spot-providers-enabled-by-default"]) %};
</script> </script>
<script src="/js/spotsbandsandmap.js?v=1782076701"></script> <script src="/js/spotsbandsandmap.js?v=1783357125"></script>
<script src="/js/spots.js?v=1782076701"></script> <script src="/js/spots.js?v=1783357125"></script>
<script>$(document).ready(function () { <script>$(document).ready(function () {
$("#nav-link-spots").addClass("active"); $("#nav-link-spots").addClass("active");
}); <!-- highlight active page in nav --></script> }); <!-- highlight active page in nav --></script>
+1 -1
View File
@@ -59,7 +59,7 @@
</div> </div>
</div> </div>
<script src="/js/status.js?v=1782076701"></script> <script src="/js/status.js?v=1783357126"></script>
<script> <script>
$(document).ready(function () { $(document).ready(function () {
$("#nav-link-status").addClass("active"); $("#nav-link-status").addClass("active");
+20 -4
View File
@@ -18,6 +18,11 @@ info:
### 1.4 ### 1.4
* Spots can now include a "propagation_mode" field, and the `/options` call enumerates the options that can have. * Spots can now include a "propagation_mode" field, and the `/options` call enumerates the options that can have.
* Added support for the Diploma de Municipios Españoles (DME) SIG
* Renamed some SIGs to avoid confusion between Towers, Tiles and Toilets
* Added `comment_names` to SIGs in the `/options`, to reflect how they might be referred to in spot comments where
it differs from their `name`.
* Added `propagation_mode` field to spots
### 1.3 ### 1.3
@@ -771,7 +776,7 @@ components:
- ZLOTA - ZLOTA
- WOTA - WOTA
- LLOTA - LLOTA
- WWTOTA - Towers
- Tiles - Tiles
- Cluster - Cluster
- RBN - RBN
@@ -790,7 +795,7 @@ components:
- HEMA - HEMA
- WCA - WCA
- MOTA - MOTA
- SIOTA - SiOTA
- ARLHS - ARLHS
- ILLW - ILLW
- ZLOTA - ZLOTA
@@ -799,11 +804,12 @@ components:
- WOTA - WOTA
- BOTA - BOTA
- LLOTA - LLOTA
- WWTOTA - Towers
- Tiles - Tiles
- WAB - WAB
- WAI - WAI
- TOTA - DME
- Toilets
example: POTA example: POTA
SIGNameIncludingNoSIG: SIGNameIncludingNoSIG:
@@ -1373,6 +1379,16 @@ components:
type: string type: string
description: The full name of the SIG description: The full name of the SIG
example: Parks on the Air example: Parks on the Air
comment_names:
type: array
description: >
Names by which this SIG may be referred to in cluster spot comments. Most SIGs have a
single entry matching their programme name (e.g. ["POTA"]), but some have none (where
the name is ambiguous with other SIGs, such as Tiles and Toilets on the Air) or multiple
entries (e.g. WWBOTA accepts both "WWBOTA" and "BOTA" since the latter is often used).
items:
type: string
example: [ "TOTA" ]
ref_regex: ref_regex:
type: string type: string
description: > description: >
+23
View File
@@ -1,5 +1,7 @@
// How often to query the server? // How often to query the server?
const REFRESH_INTERVAL_SEC = 60 * 10; const REFRESH_INTERVAL_SEC = 60 * 10;
// Last time the alerts list was updated on display.
let lastUpdateTime;
// Storage for the alert data that the server gives us. // Storage for the alert data that the server gives us.
let alerts = []; let alerts = [];
@@ -309,6 +311,27 @@ function filtersUpdated() {
saveSettings(); saveSettings();
} }
// Update the refresh timing display
function updateRefreshDisplay() {
if (lastUpdateTime != null) {
let secSinceUpdate = moment.duration(moment().diff(lastUpdateTime)).asSeconds();
let count = REFRESH_INTERVAL_SEC;
let updatingString = "Updating..."
if (secSinceUpdate < REFRESH_INTERVAL_SEC) {
count = REFRESH_INTERVAL_SEC - secSinceUpdate;
let number;
if (count <= 60) {
number = count.toFixed(0);
updatingString = "<span class='nowrap'>Updating in " + number + " second" + (number !== "1" ? "s" : "") + ".</span>";
} else {
number = Math.round(count / 60.0).toFixed(0);
updatingString = "<span class='nowrap'>Updating in " + number + " minute" + (number !== "1" ? "s" : "") + ".</span>";
}
}
$("#timing-container").html("Last updated at " + lastUpdateTime.format('HH:mm') + " UTC. " + updatingString);
}
}
// Startup // Startup
$(document).ready(function () { $(document).ready(function () {
// Call loadOptions(), this will then trigger loading alerts and setting up timers. // Call loadOptions(), this will then trigger loading alerts and setting up timers.
+67 -17
View File
@@ -1,5 +1,9 @@
// How often to query the server? // SSE connection for live spot updates
const REFRESH_INTERVAL_SEC = 60; let evtSource;
let restartSSEOnErrorTimeoutId;
// Debounce timer so rapid SSE bursts only trigger one updateBands() call, as these could trigger a flash of the user
// visible content
let updateBandsDebounceId;
// A couple of constants that must match what's in CSS. We need to know them before the content actually renders, so we // A couple of constants that must match what's in CSS. We need to know them before the content actually renders, so we
// can't just ask the elements themselves for their dimensions. // can't just ask the elements themselves for their dimensions.
@@ -12,19 +16,62 @@ BAND_COLUMN_SPOT_DIV_HEIGHT_PX = BAND_COLUMN_FONT_SIZE * 1.6;
// Load spots and populate the bands display. // Load spots and populate the bands display.
function loadSpots() { function loadSpots() {
$.getJSON('/api/v1/spots' + buildQueryString(false), function (jsonData) { // Close any existing SSE connection before fetching fresh data
// Store last updated time if (evtSource != null) {
lastUpdateTime = moment.utc(); evtSource.close();
updateRefreshDisplay(); }
$.getJSON('/api/v1/spots' + buildQueryString(), function (jsonData) {
// Store data // Store data
spots = jsonData; spots = jsonData;
// Update bands display // Update bands display
updateBands(); updateBands();
// Start the ongoing SSE connection
startSSEConnection();
}); });
} }
// Build a query string for the API, based on the filters that the user has selected. // Start an SSE connection to receive new spots as they arrive.
function buildQueryString(includeCredentials) { function startSSEConnection() {
if (evtSource != null) {
evtSource.close();
}
evtSource = new EventSource('/api/v1/spots/stream' + buildQueryString());
evtSource.onmessage = function (event) {
const newSpot = JSON.parse(event.data);
// Replace any existing spot for this callsign
spots = spots.filter(s => newSpot["dx_call"] !== s["dx_call"]);
spots.unshift(newSpot);
// Debounce. Wait 500ms after the last message before re-rendering to avoid too many ugly flashes
clearTimeout(updateBandsDebounceId);
updateBandsDebounceId = setTimeout(updateBands, 5000);
};
evtSource.onerror = function () {
if (evtSource != null) {
evtSource.close();
}
clearTimeout(restartSSEOnErrorTimeoutId);
restartSSEOnErrorTimeoutId = setTimeout(startSSEConnection, 1000);
};
}
// Remove spots from the display that are older than the selected max age.
function expireOldSpots() {
const maxAgeSeconds = parseInt($("#max-spot-age option:selected").val());
const cutoff = (Date.now() / 1000) - maxAgeSeconds;
const before = spots.length;
spots = spots.filter(s => s["time"] && s["time"] >= cutoff);
if (spots.length !== before) {
updateBands();
}
}
// Build a query string for the API, based on the filters that the user has selected. There's no need for credentials
// in the bands page's version of this, because nothing QRZ.com/HamQTH can provide will affect the display.
function buildQueryString() {
let str = "?"; let str = "?";
["dx_continent", "de_continent", "mode", "source", "band", "sig"].forEach(fn => { ["dx_continent", "de_continent", "mode", "source", "band", "sig"].forEach(fn => {
if (!allFilterOptionsSelected(fn)) { if (!allFilterOptionsSelected(fn)) {
@@ -34,9 +81,6 @@ function buildQueryString(includeCredentials) {
str = str + "max_age=" + $("#max-spot-age option:selected").val(); str = str + "max_age=" + $("#max-spot-age option:selected").val();
// Additional filters for the bands view: No dupes, no QRT // Additional filters for the bands view: No dupes, no QRT
str = str + "&dedupe=true&allow_qrt=false"; str = str + "&dedupe=true&allow_qrt=false";
if (includeCredentials) {
str = str + getCredentialQueryString();
}
return str; return str;
} }
@@ -257,22 +301,28 @@ function loadOptions() {
// Load settings from settings storage now all the controls are available // Load settings from settings storage now all the controls are available
loadSettings(); loadSettings();
// Load spots and set up the timer // Load spots and start SSE for live updates
loadSpots(); loadSpots();
setInterval(loadSpots, REFRESH_INTERVAL_SEC * 1000); // Set up spot expiry checker
setInterval(expireOldSpots, 60 * 1000);
}); });
} }
// Method called when any display property is changed to reload the map and persist the display settings. // Method called when any display property is changed to reload the bands display and persist settings.
function displayUpdated() { function displayUpdated() {
updateMap(); updateBands();
saveSettings(); saveSettings();
} }
// Startup // Startup
$(document).ready(function () { $(document).ready(function () {
// Close SSE connection cleanly when navigating away
window.addEventListener('beforeunload', function () {
if (evtSource != null) {
evtSource.close();
}
});
// Call loadOptions(), this will then trigger loading spots and setting up timers. // Call loadOptions(), this will then trigger loading spots and setting up timers.
loadOptions(); loadOptions();
// Update the refresh timing display every second
setInterval(updateRefreshDisplay, 1000);
}); });
-23
View File
@@ -1,7 +1,5 @@
// Storage for the options that the server gives us. This will define our filters. // Storage for the options that the server gives us. This will define our filters.
let options = {}; let options = {};
// Last time we updated the spots/alerts list on display.
let lastUpdateTime;
// Normally load user settings from local storage, unless embedded mode is in use // Normally load user settings from local storage, unless embedded mode is in use
let useLocalStorage = true; let useLocalStorage = true;
@@ -145,27 +143,6 @@ function toggleFilterButtons(filterQuery, state) {
filtersUpdated(); filtersUpdated();
} }
// Update the refresh timing display
function updateRefreshDisplay() {
if (lastUpdateTime != null) {
let secSinceUpdate = moment.duration(moment().diff(lastUpdateTime)).asSeconds();
let count = REFRESH_INTERVAL_SEC;
let updatingString = "Updating..."
if (secSinceUpdate < REFRESH_INTERVAL_SEC) {
count = REFRESH_INTERVAL_SEC - secSinceUpdate;
let number;
if (count <= 60) {
number = count.toFixed(0);
updatingString = "<span class='nowrap'>Updating in " + number + " second" + (number !== "1" ? "s" : "") + ".</span>";
} else {
number = Math.round(count / 60.0).toFixed(0);
updatingString = "<span class='nowrap'>Updating in " + number + " minute" + (number !== "1" ? "s" : "") + ".</span>";
}
}
$("#timing-container").html("Last updated at " + lastUpdateTime.format('HH:mm') + " UTC. " + updatingString);
}
}
// When the "use local time" field is changed, reload the table and save settings // When the "use local time" field is changed, reload the table and save settings
function timeZoneUpdated() { function timeZoneUpdated() {
updateTable(); updateTable();
+124 -24
View File
@@ -1,6 +1,3 @@
// How often to query the server?
const REFRESH_INTERVAL_SEC = 60;
// Colours // Colours
const MAIDENHEAD_GRID_COLOR_LIGHT = 'rgba(200, 140, 140, 1.0)'; const MAIDENHEAD_GRID_COLOR_LIGHT = 'rgba(200, 140, 140, 1.0)';
const CQ_ZONES_COLOR_LIGHT = 'rgba(140, 200, 140, 1.0)'; const CQ_ZONES_COLOR_LIGHT = 'rgba(140, 200, 140, 1.0)';
@@ -11,6 +8,15 @@ const CQ_ZONES_COLOR_DARK = 'rgba(60, 120, 60, 1.0)';
const ITU_ZONES_COLOR_DARK = 'rgba(120, 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)'; const WAB_WAI_GRID_COLOR_DARK = 'rgba(60, 60, 120, 1.0)';
// SSE connection for live spot updates
let evtSource;
let restartSSEOnErrorTimeoutId;
// Map dx_call to a pair of marker & geodesic line, so we can de-duplicate spots as they arrive and only show
// one marker per dx_call. The key here is actually dx_call + SSID if the spot gives us an SSID; this is mostly for
// if APRS spots are enabled so we can ID a home and mobile station separately rather than our marker oscillating
// between the two as updates come in.
let spotMarkers = new Map();
// Map layers // Map layers
let backgroundTileLayer; let backgroundTileLayer;
let markersLayer; let markersLayer;
@@ -28,7 +34,13 @@ let firstLoad = true;
// Load spots and populate the map. // Load spots and populate the map.
function loadSpots() { function loadSpots() {
$.getJSON('/api/v1/spots' + buildQueryString(true), function (jsonData) { // Close any existing SSE connection before fetching fresh data
if (evtSource != null) {
evtSource.close();
}
// On first fetch, don't include QRZ/HamQTH credentials. This will cause some inaccurate
// marker positions but avoids having to wait a minute or more for all the lookups to fire.
$.getJSON('/api/v1/spots' + buildQueryString(false), function (jsonData) {
// Store data // Store data
spots = jsonData; spots = jsonData;
// Update map // Update map
@@ -36,9 +48,102 @@ function loadSpots() {
if ($("#showTerminator")[0].checked) { if ($("#showTerminator")[0].checked) {
terminator.setTime(); terminator.setTime();
} }
// Start the ongoing SSE connection
startSSEConnection();
}); });
} }
// Start the SSE connection to receive new spots as they arrive
function startSSEConnection() {
if (evtSource != null) {
evtSource.close();
}
// SSE is going to fetch only a few spots at a time, so now we include QRZ/HamQTH credentials because the delay won't be significant.
evtSource = new EventSource('/api/v1/spots/stream' + buildQueryString(true));
evtSource.onmessage = function (event) {
const newSpot = JSON.parse(event.data);
const key = spotKey(newSpot);
// Remove existing marker/geodesic for this callsign if present
removeSpotFromMap(key);
spots = spots.filter(s => spotKey(s) !== key);
// Skip spots with no map coordinates
if (newSpot["dx_latitude"] == null || newSpot["dx_longitude"] == null) {
return;
}
// Add to data store and map
spots.unshift(newSpot);
addSpotToMap(newSpot);
};
evtSource.onerror = function () {
if (evtSource != null) {
evtSource.close();
}
clearTimeout(restartSSEOnErrorTimeoutId);
restartSSEOnErrorTimeoutId = setTimeout(startSSEConnection, 1000);
};
}
// Remove spots from the map that are older than the selected max age.
function expireOldSpots() {
const maxAgeSeconds = parseInt($("#max-spot-age option:selected").val());
const cutoff = (Date.now() / 1000) - maxAgeSeconds;
spots = spots.filter(function (s) {
if (s["time"] && s["time"] < cutoff) {
removeSpotFromMap(spotKey(s));
return false;
}
return true;
});
}
// Returns a unique key for a spot based on its callsign and SSID. This is in case the APRS spot source is enabled and
// we want to display all SSIDs for a given callsign, not just the latest one.
function spotKey(s) {
return s["dx_call"] + (s["dx_ssid"] ? "-" + s["dx_ssid"] : "");
}
// Add a single spot's marker and geodesic to the map and to spotMarkers
function addSpotToMap(s) {
const m = L.marker([s["dx_latitude"], s["dx_longitude"]], {icon: getIcon(s)});
m.bindPopup(getTooltipText(s));
markersLayer.addLayer(m);
oms.addMarker(m);
let geodesic = null;
if ($("#mapShowGeodesics")[0].checked && s["de_latitude"] != null && s["de_longitude"] != null) {
try {
geodesic = L.geodesic([[s["de_latitude"], s["de_longitude"]], m.getLatLng()], {
color: bandToColor(s['band']),
wrap: false,
steps: 5
});
geodesicsLayer.addLayer(geodesic);
} catch (e) {
// Not sure what causes these but better to continue than to crash out
}
}
spotMarkers.set(spotKey(s), {marker: m, geodesic: geodesic});
}
// Remove a spot's marker and geodesic from the map and from spotMarkers.
function removeSpotFromMap(key) {
const entry = spotMarkers.get(key);
if (entry) {
markersLayer.removeLayer(entry.marker);
oms.removeMarker(entry.marker);
if (entry.geodesic) {
geodesicsLayer.removeLayer(entry.geodesic);
}
spotMarkers.delete(key);
}
}
// Build a query string for the API, based on the filters that the user has selected. // Build a query string for the API, based on the filters that the user has selected.
function buildQueryString(includeCredentials) { function buildQueryString(includeCredentials) {
let str = "?"; let str = "?";
@@ -62,27 +167,14 @@ function updateMap() {
markersLayer.clearLayers(); markersLayer.clearLayers();
geodesicsLayer.clearLayers(); geodesicsLayer.clearLayers();
oms.clearMarkers(); oms.clearMarkers();
spotMarkers.clear();
// Make new markers for all spots that match the filter // Make new markers for all spots
spots.forEach(function (s) { spots.forEach(function (s) {
const m = L.marker([s["dx_latitude"], s["dx_longitude"]], {icon: getIcon(s)}); if (s["dx_latitude"] == null || s["dx_longitude"] == null) {
m.bindPopup(getTooltipText(s)); return;
markersLayer.addLayer(m);
oms.addMarker(m);
// Create geodesics if required
if ($("#mapShowGeodesics")[0].checked && s["de_latitude"] != null && s["de_longitude"] != null) {
try {
const geodesic = L.geodesic([[s["de_latitude"], s["de_longitude"]], m.getLatLng()], {
color: bandToColor(s['band']),
wrap: false,
steps: 5
});
geodesicsLayer.addLayer(geodesic);
} catch (e) {
// Not sure what causes these but better to continue than to crash out
}
} }
addSpotToMap(s);
}); });
// On first load, zoom to the extent of the markers // On first load, zoom to the extent of the markers
@@ -239,9 +331,10 @@ function loadOptions() {
enableITUZones($("#showITUZones")[0].checked); enableITUZones($("#showITUZones")[0].checked);
enableWABWAIGrid($("#showWABWAIGrid")[0].checked); enableWABWAIGrid($("#showWABWAIGrid")[0].checked);
// Load spots and set up the timer // Load spots and start SSE for live updates
loadSpots(); loadSpots();
setInterval(loadSpots, REFRESH_INTERVAL_SEC * 1000); // Set up spot expiry checker
setInterval(expireOldSpots, 60 * 1000);
}); });
} }
@@ -483,6 +576,13 @@ function setUpMap() {
// Startup // Startup
$(document).ready(function () { $(document).ready(function () {
// Close SSE connection cleanly when navigating away
window.addEventListener('beforeunload', function () {
if (evtSource != null) {
evtSource.close();
}
});
// Hide the extra things that need to be hidden on this page // Hide the extra things that need to be hidden on this page
$(".hideonmap").hide(); $(".hideonmap").hide();
// Set up map // Set up map
+2 -2
View File
@@ -332,12 +332,12 @@ const SIG_ICONS = {
"BOTA": "fa-umbrella-beach", "BOTA": "fa-umbrella-beach",
"KRMNPA": "fa-earth-oceania", "KRMNPA": "fa-earth-oceania",
"LLOTA": "fa-water", "LLOTA": "fa-water",
"WWTOTA": "fa-tower-observation", "Towers": "fa-tower-observation",
"WAB": "fa-table-cells-large", "WAB": "fa-table-cells-large",
"WAI": "fa-table-cells-large", "WAI": "fa-table-cells-large",
"DME": "fa-building", "DME": "fa-building",
"Tiles": "fa-square", "Tiles": "fa-square",
"TOTA": "fa-toilet" "Toilets": "fa-toilet"
} }
// Get the Font Awesome icon for a given SIG. If the SIG is unknown, the provided default symbol will be returned // Get the Font Awesome icon for a given SIG. If the SIG is unknown, the provided default symbol will be returned