mirror of
https://git.ianrenton.com/ian/spothole.git
synced 2026-02-04 09:14:30 +00:00
Compare commits
22 Commits
3-sse-endp
...
1.1
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
059d9364eb | ||
|
|
a3ca590ca3 | ||
|
|
cfff8dd832 | ||
|
|
d1a5bfe9c3 | ||
|
|
da2827f559 | ||
|
|
220c9378cf | ||
|
|
e1cdc5b857 | ||
|
|
5482da0e69 | ||
|
|
f31148686d | ||
|
|
a444be8fe9 | ||
|
|
3f117a47d6 | ||
|
|
06d582ae2d | ||
|
|
5bf45dba46 | ||
|
|
f4ae6b610e | ||
|
|
6af15e4cfd | ||
|
|
6d9bf3d4ec | ||
|
|
9b737a8176 | ||
|
|
05bc65337f | ||
|
|
d2c1dbb377 | ||
|
|
6cf1b38355 | ||
|
|
ac566553d8 | ||
|
|
bcc40d1416 |
@@ -30,7 +30,7 @@ URL parameters can be used to trigger an "embedded" mode which hides the headers
|
|||||||
|
|
||||||
Setting `embedded` to true is important for the rest of the settings to be applied; otherwise, the user's defaults will be used in preference to the URL params.
|
Setting `embedded` to true is important for the rest of the settings to be applied; otherwise, the user's defaults will be used in preference to the URL params.
|
||||||
|
|
||||||
These are supplied with the URL to the page you want to embed, for example for an embedded version of the band map in dark mode, use `https://spothole.com/bands?embedded=true&dark-mode=true`. For an embedded version of the main spots/home page in the system light/dark mode, use `https://spothole.com/?embedded=true`. For dark mode showing 70cm TOTA spots only, use `https://spothole.com/?embedded=true&dark-mode=true&filter-sigs=TOTA&filter-bands=70cm`. Providing no URL params causes the page to be loaded in the normal way it would when accessed directly in the user's browser.
|
These are supplied with the URL to the page you want to embed, for example for an embedded version of the band map in dark mode, use `https://spothole.app/bands?embedded=true&dark-mode=true`. For an embedded version of the main spots/home page in the system light/dark mode, use `https://spothole.app/?embedded=true`. For dark mode showing 70cm TOTA spots only, use `https://spothole.app/?embedded=true&dark-mode=true&sig=TOTA&band=70cm`. Providing no URL params causes the page to be loaded in the normal way it would when accessed directly in the user's browser.
|
||||||
|
|
||||||
The supported parameters are as follows. Generally these match the equivalent parameters in the real Spothole API, where a mapping exists.
|
The supported parameters are as follows. Generally these match the equivalent parameters in the real Spothole API, where a mapping exists.
|
||||||
|
|
||||||
@@ -157,6 +157,8 @@ server {
|
|||||||
|
|
||||||
location / {
|
location / {
|
||||||
add_header Access-Control-Allow-Origin $xssorigin;
|
add_header Access-Control-Allow-Origin $xssorigin;
|
||||||
|
proxy_http_version 1.1;
|
||||||
|
proxy_set_header Connection "";
|
||||||
proxy_pass http://127.0.0.1:8080;
|
proxy_pass http://127.0.0.1:8080;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -219,6 +221,7 @@ To navigate your way around the source code, this list may help.
|
|||||||
|
|
||||||
* `/` - Main script (`spothole.py`), pip `requirements.txt`, config, README, etc.
|
* `/` - Main script (`spothole.py`), pip `requirements.txt`, config, README, etc.
|
||||||
* `/images` - Image sources
|
* `/images` - Image sources
|
||||||
|
* `/datafiles` - Local data sources (differentiated from the majority of data files which are loaded from URLs and cached in `/cache`)
|
||||||
* `/cache` - Directory where static-ish data downloaded from the internet is cached to avoid rapid re-requests, and where spot/alert data is cached so that it survives a software restart. Created on first run.
|
* `/cache` - Directory where static-ish data downloaded from the internet is cached to avoid rapid re-requests, and where spot/alert data is cached so that it survives a software restart. Created on first run.
|
||||||
|
|
||||||
### Extending the server
|
### Extending the server
|
||||||
|
|||||||
@@ -76,7 +76,6 @@ class NG3K(HTTPAlertProvider):
|
|||||||
dx_country=dx_country,
|
dx_country=dx_country,
|
||||||
freqs_modes=bands + (("; " + modes) if modes != "" else ""),
|
freqs_modes=bands + (("; " + modes) if modes != "" else ""),
|
||||||
comment=by + "; " + comment + "; " + qsl_info,
|
comment=by + "; " + comment + "; " + qsl_info,
|
||||||
icon="globe-africa",
|
|
||||||
start_time=start_timestamp,
|
start_time=start_timestamp,
|
||||||
end_time=end_timestamp,
|
end_time=end_timestamp,
|
||||||
is_dxpedition=True)
|
is_dxpedition=True)
|
||||||
|
|||||||
@@ -20,13 +20,18 @@ class SOTA(HTTPAlertProvider):
|
|||||||
# Iterate through source data
|
# Iterate through source data
|
||||||
for source_alert in http_response.json():
|
for source_alert in http_response.json():
|
||||||
# Convert to our alert format
|
# Convert to our alert format
|
||||||
|
details = source_alert["summitDetails"].split(", ")
|
||||||
|
summit_name = details[0]
|
||||||
|
summit_points = None
|
||||||
|
if len(details) > 2:
|
||||||
|
summit_points = int(details[-1].split(" ")[0])
|
||||||
alert = Alert(source=self.name,
|
alert = Alert(source=self.name,
|
||||||
source_id=source_alert["id"],
|
source_id=source_alert["id"],
|
||||||
dx_calls=[source_alert["activatingCallsign"].upper()],
|
dx_calls=[source_alert["activatingCallsign"].upper()],
|
||||||
dx_names=[source_alert["activatorName"].upper()],
|
dx_names=[source_alert["activatorName"].upper()],
|
||||||
freqs_modes=source_alert["frequency"],
|
freqs_modes=source_alert["frequency"],
|
||||||
comment=source_alert["comments"],
|
comment=source_alert["comments"],
|
||||||
sig_refs=[SIGRef(id=source_alert["associationCode"] + "/" + source_alert["summitCode"], sig="SOTA", name=source_alert["summitDetails"])],
|
sig_refs=[SIGRef(id=source_alert["associationCode"] + "/" + source_alert["summitCode"], sig="SOTA", name=summit_name, activation_score=summit_points)],
|
||||||
start_time=datetime.strptime(source_alert["dateActivated"],
|
start_time=datetime.strptime(source_alert["dateActivated"],
|
||||||
"%Y-%m-%dT%H:%M:%SZ").replace(tzinfo=pytz.UTC).timestamp(),
|
"%Y-%m-%dT%H:%M:%SZ").replace(tzinfo=pytz.UTC).timestamp(),
|
||||||
is_dxpedition=False)
|
is_dxpedition=False)
|
||||||
|
|||||||
@@ -59,39 +59,63 @@ spot-providers:
|
|||||||
enabled: true
|
enabled: true
|
||||||
host: "hrd.wa9pie.net"
|
host: "hrd.wa9pie.net"
|
||||||
port: 8000
|
port: 8000
|
||||||
login_prompt: "login: "
|
# Prompt the cluster node gives when asking for a callsign to log in. Varies between cluster node software.
|
||||||
|
login_prompt: "login:"
|
||||||
|
# Callsign Spothole will use to log into this cluster. Ensure the SSID (e.g. -99) is different to any personal
|
||||||
|
# connection you might make to this cluster node.
|
||||||
|
login_callsign: "N0CALL-99"
|
||||||
|
# Whether to allow RBN spots that come via this cluster. If you don't want RBN spots or you are making a separate
|
||||||
|
# connection to RBN directly, leave this as False. If you want RBN spots from this cluster, set this to True. (Make
|
||||||
|
# sure you aren't also separately connecting to RBN directly, otherwise you may get duplicate spots.) Note that not
|
||||||
|
# all clusters sent RBN spots anyway.
|
||||||
|
allow_rbn_spots: false
|
||||||
-
|
-
|
||||||
class: "DXCluster"
|
class: "DXCluster"
|
||||||
name: "W3LPL Cluster"
|
name: "W3LPL Cluster"
|
||||||
enabled: false
|
enabled: false
|
||||||
host: "w3lpl.net"
|
host: "w3lpl.net"
|
||||||
port: 7373
|
port: 7373
|
||||||
login_prompt: "Please enter your call: "
|
# Prompt the cluster node gives when asking for a callsign to log in. Varies between cluster node software.
|
||||||
|
login_prompt: "Please enter your call:"
|
||||||
|
# Callsign Spothole will use to log into this cluster. Ensure the SSID (e.g. -99) is different to any personal
|
||||||
|
# connection you might make to this cluster node.
|
||||||
|
login_callsign: "N0CALL-99"
|
||||||
|
# Whether to allow RBN spots that come via this cluster. If you don't want RBN spots or you are making a separate
|
||||||
|
# connection to RBN directly, leave this as False. If you want RBN spots from this cluster, set this to True. (Make
|
||||||
|
# sure you aren't also separately connecting to RBN directly, otherwise you may get duplicate spots.) Note that not
|
||||||
|
# all clusters sent RBN spots anyway.
|
||||||
|
allow_rbn_spots: false
|
||||||
-
|
-
|
||||||
class: "RBN"
|
class: "RBN"
|
||||||
name: "RBN CW/RTTY"
|
name: "RBN CW/RTTY"
|
||||||
enabled: false
|
enabled: false
|
||||||
port: 7000
|
port: 7000
|
||||||
|
# This setting doesn't affect the spot provider itself, or anything in the back-end of Spothole, just the web UI.
|
||||||
|
# By default spots from all enabled providers will be shown in the web UI. However, you might want RBN data to be
|
||||||
|
# received by Spothole but not shown on the web UI unless the user explicitly turns it on. For that behaviour,
|
||||||
|
# set enabled to true, but enabled-by-default-in-web-ui to false.
|
||||||
|
enabled-by-default-in-web-ui: false
|
||||||
-
|
-
|
||||||
class: "RBN"
|
class: "RBN"
|
||||||
name: "RBN FT8"
|
name: "RBN FT8"
|
||||||
enabled: false
|
enabled: false
|
||||||
port: 7001
|
port: 7001
|
||||||
|
enabled-by-default-in-web-ui: false
|
||||||
-
|
-
|
||||||
class: "UKPacketNet"
|
class: "UKPacketNet"
|
||||||
name: "UK Packet Radio Net"
|
name: "UK Packet Radio Net"
|
||||||
enabled: false
|
enabled: false
|
||||||
|
enabled-by-default-in-web-ui: false
|
||||||
-
|
-
|
||||||
class: "XOTA"
|
class: "XOTA"
|
||||||
name: "39C3 TOTA"
|
name: "39C3 TOTA"
|
||||||
enabled: false
|
enabled: false
|
||||||
url: "wss://dev.39c3.totawatch.de/api/spot/live"
|
url: "wss://dev.39c3.totawatch.de/api/spot/live"
|
||||||
# Fixed SIG/latitude/longitude for all spots from a provider is currently only a feature for the "XOTA" provider,
|
# 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
|
# 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.
|
# programmes and so different URLs provide different programmes.
|
||||||
sig: "TOTA"
|
sig: "TOTA"
|
||||||
latitude: 53.5622678
|
locations-csv: "datafiles/39c3-tota.csv"
|
||||||
longitude: 9.9855205
|
|
||||||
|
|
||||||
|
|
||||||
# Alert providers to use. Same setup as the spot providers list above.
|
# Alert providers to use. Same setup as the spot providers list above.
|
||||||
|
|||||||
@@ -5,7 +5,8 @@ import yaml
|
|||||||
|
|
||||||
# Check you have a config file
|
# Check you have a config file
|
||||||
if not os.path.isfile("config.yml"):
|
if not os.path.isfile("config.yml"):
|
||||||
logging.error("Your config file is missing. Ensure you have copied config-example.yml to config.yml and updated it according to your needs.")
|
logging.error(
|
||||||
|
"Your config file is missing. Ensure you have copied config-example.yml to config.yml and updated it according to your needs.")
|
||||||
exit()
|
exit()
|
||||||
|
|
||||||
# Load config
|
# Load config
|
||||||
@@ -17,4 +18,9 @@ MAX_ALERT_AGE = config["max-alert-age-sec"]
|
|||||||
SERVER_OWNER_CALLSIGN = config["server-owner-callsign"]
|
SERVER_OWNER_CALLSIGN = config["server-owner-callsign"]
|
||||||
WEB_SERVER_PORT = config["web-server-port"]
|
WEB_SERVER_PORT = config["web-server-port"]
|
||||||
ALLOW_SPOTTING = config["allow-spotting"]
|
ALLOW_SPOTTING = config["allow-spotting"]
|
||||||
WEB_UI_OPTIONS = config["web-ui-options"]
|
WEB_UI_OPTIONS = config["web-ui-options"]
|
||||||
|
|
||||||
|
# For ease of config, each spot provider owns its own config about whether it should be enabled by default in the web UI
|
||||||
|
# but for consistency we provide this to the front-end in web-ui-options because it has no impact outside of the web UI.
|
||||||
|
WEB_UI_OPTIONS["spot-providers-enabled-by-default"] = [p["name"] for p in config["spot-providers"] if p["enabled"] and (
|
||||||
|
"enabled-by-default-in-web-ui" not in p or p["enabled-by-default-in-web-ui"] == True)]
|
||||||
|
|||||||
@@ -4,7 +4,7 @@ from data.sig import SIG
|
|||||||
|
|
||||||
# General software
|
# General software
|
||||||
SOFTWARE_NAME = "Spothole by M0TRT"
|
SOFTWARE_NAME = "Spothole by M0TRT"
|
||||||
SOFTWARE_VERSION = "1.1-pre"
|
SOFTWARE_VERSION = "1.1"
|
||||||
|
|
||||||
# HTTP headers used for spot providers that use HTTP
|
# HTTP headers used for spot providers that use HTTP
|
||||||
HTTP_HEADERS = {"User-Agent": SOFTWARE_NAME + ", v" + SOFTWARE_VERSION + " (operated by " + SERVER_OWNER_CALLSIGN + ")"}
|
HTTP_HEADERS = {"User-Agent": SOFTWARE_NAME + ", v" + SOFTWARE_VERSION + " (operated by " + SERVER_OWNER_CALLSIGN + ")"}
|
||||||
@@ -12,25 +12,25 @@ HAMQTH_PRG = (SOFTWARE_NAME + " v" + SOFTWARE_VERSION + " operated by " + SERVER
|
|||||||
|
|
||||||
# Special Interest Groups
|
# Special Interest Groups
|
||||||
SIGS = [
|
SIGS = [
|
||||||
SIG(name="POTA", description="Parks on the Air", icon="tree", ref_regex=r"[A-Z]{2}\-\d{4,5}"),
|
SIG(name="POTA", description="Parks on the Air", ref_regex=r"[A-Z]{2}\-\d{4,5}"),
|
||||||
SIG(name="SOTA", description="Summits on the Air", icon="mountain-sun", ref_regex=r"[A-Z0-9]{1,3}\/[A-Z]{2}\-\d{3}"),
|
SIG(name="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", icon="seedling", ref_regex=r"[A-Z0-9]{1,3}FF\-\d{4}"),
|
SIG(name="WWFF", description="World Wide Flora & Fauna", ref_regex=r"[A-Z0-9]{1,3}FF\-\d{4}"),
|
||||||
SIG(name="GMA", description="Global Mountain Activity", icon="person-hiking", ref_regex=r"[A-Z0-9]{1,3}\/[A-Z]{2}\-\d{3}"),
|
SIG(name="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", icon="radiation", ref_regex=r"B\/[A-Z0-9]{1,3}\-\d{3,4}"),
|
SIG(name="WWBOTA", 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", icon="mound", ref_regex=r"[A-Z0-9]{1,3}\/[A-Z]{3}\-\d{3}"),
|
SIG(name="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", icon="umbrella-beach", ref_regex=r"[A-Z]{2}\-\d{3}"),
|
SIG(name="IOTA", description="Islands on the Air", ref_regex=r"[A-Z]{2}\-\d{3}"),
|
||||||
SIG(name="MOTA", description="Mills on the Air", icon="fan", ref_regex=r"X\d{4-6}"),
|
SIG(name="MOTA", description="Mills on the Air", ref_regex=r"X\d{4-6}"),
|
||||||
SIG(name="ARLHS", description="Amateur Radio Lighthouse Society", icon="tower-observation", ref_regex=r"[A-Z]{3}\-\d{3,4}"),
|
SIG(name="ARLHS", description="Amateur Radio Lighthouse Society", ref_regex=r"[A-Z]{3}\-\d{3,4}"),
|
||||||
SIG(name="ILLW", description="International Lighthouse & Lightship Weekend", icon="tower-observation", ref_regex=r"[A-Z]{2}\d{4}"),
|
SIG(name="ILLW", description="International Lighthouse & Lightship Weekend", ref_regex=r"[A-Z]{2}\d{4}"),
|
||||||
SIG(name="SIOTA", description="Silos on the Air", icon="wheat-awn", ref_regex=r"[A-Z]{2}\-[A-Z]{3}\d"),
|
SIG(name="SIOTA", description="Silos on the Air", ref_regex=r"[A-Z]{2}\-[A-Z]{3}\d"),
|
||||||
SIG(name="WCA", description="World Castles Award", icon="chess-rook", ref_regex=r"[A-Z0-9]{1,3}\-\d{5}"),
|
SIG(name="WCA", description="World Castles Award", ref_regex=r"[A-Z0-9]{1,3}\-\d{5}"),
|
||||||
SIG(name="ZLOTA", description="New Zealand on the Air", icon="kiwi-bird", ref_regex=r"ZL[A-Z]/[A-Z]{2}\-\d{3,4}"),
|
SIG(name="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", icon="w", ref_regex=r"[A-Z]{3}-[0-9]{2}"),
|
SIG(name="WOTA", description="Wainwrights on the Air", ref_regex=r"[A-Z]{3}-[0-9]{2}"),
|
||||||
SIG(name="BOTA", description="Beaches on the Air", icon="water"),
|
SIG(name="BOTA", description="Beaches on the Air"),
|
||||||
SIG(name="KRMNPA", description="Keith Roget Memorial National Parks Award", icon="earth-oceania"),
|
SIG(name="KRMNPA", description="Keith Roget Memorial National Parks Award"),
|
||||||
SIG(name="WAB", description="Worked All Britain", icon="table-cells-large", ref_regex=r"[A-Z]{1,2}[0-9]{2}"),
|
SIG(name="WAB", description="Worked All Britain", ref_regex=r"[A-Z]{1,2}[0-9]{2}"),
|
||||||
SIG(name="WAI", description="Worked All Ireland", icon="table-cells-large", ref_regex=r"[A-Z][0-9]{2}"),
|
SIG(name="WAI", description="Worked All Ireland", ref_regex=r"[A-Z][0-9]{2}"),
|
||||||
SIG(name="TOTA", description="Toilets on the Air", icon="toilet", ref_regex=r"T\-[0-9]{2}")
|
SIG(name="TOTA", 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".
|
||||||
@@ -42,33 +42,33 @@ MODE_TYPES = ["CW", "PHONE", "DATA"]
|
|||||||
|
|
||||||
# Band definitions
|
# Band definitions
|
||||||
BANDS = [
|
BANDS = [
|
||||||
Band(name="2200m", start_freq=135700, end_freq=137800, color="#ff4500", contrast_color="white"),
|
Band(name="2200m", start_freq=135700, end_freq=137800),
|
||||||
Band(name="600m", start_freq=472000, end_freq=479000, color="#1e90ff", contrast_color="white"),
|
Band(name="600m", start_freq=472000, end_freq=479000),
|
||||||
Band(name="160m", start_freq=1800000, end_freq=2000000, color="#7cfc00", contrast_color="black"),
|
Band(name="160m", start_freq=1800000, end_freq=2000000),
|
||||||
Band(name="80m", start_freq=3500000, end_freq=4000000, color="#e550e5", contrast_color="black"),
|
Band(name="80m", start_freq=3500000, end_freq=4000000),
|
||||||
Band(name="60m", start_freq=5250000, end_freq=5410000, color="#00008b", contrast_color="white"),
|
Band(name="60m", start_freq=5250000, end_freq=5410000),
|
||||||
Band(name="40m", start_freq=7000000, end_freq=7300000, color="#5959ff", contrast_color="white"),
|
Band(name="40m", start_freq=7000000, end_freq=7300000),
|
||||||
Band(name="30m", start_freq=10100000, end_freq=10150000, color="#62d962", contrast_color="black"),
|
Band(name="30m", start_freq=10100000, end_freq=10150000),
|
||||||
Band(name="20m", start_freq=14000000, end_freq=14350000, color="#f2c40c", contrast_color="black"),
|
Band(name="20m", start_freq=14000000, end_freq=14350000),
|
||||||
Band(name="17m", start_freq=18068000, end_freq=18168000, color="#f2f261", contrast_color="black"),
|
Band(name="17m", start_freq=18068000, end_freq=18168000),
|
||||||
Band(name="15m", start_freq=21000000, end_freq=21450000, color="#cca166", contrast_color="black"),
|
Band(name="15m", start_freq=21000000, end_freq=21450000),
|
||||||
Band(name="12m", start_freq=24890000, end_freq=24990000, color="#b22222", contrast_color="white"),
|
Band(name="12m", start_freq=24890000, end_freq=24990000),
|
||||||
Band(name="11m", start_freq=26965000, end_freq=27405000, color="#00ff00", contrast_color="black"),
|
Band(name="11m", start_freq=26965000, end_freq=27405000),
|
||||||
Band(name="10m", start_freq=28000000, end_freq=29700000, color="#ff69b4", contrast_color="black"),
|
Band(name="10m", start_freq=28000000, end_freq=29700000),
|
||||||
Band(name="6m", start_freq=50000000, end_freq=54000000, color="#FF0000", contrast_color="white"),
|
Band(name="6m", start_freq=50000000, end_freq=54000000),
|
||||||
Band(name="5m", start_freq=56000000, end_freq=60500000, color="#e0e0e0", contrast_color="black"),
|
Band(name="5m", start_freq=56000000, end_freq=60500000),
|
||||||
Band(name="4m", start_freq=70000000, end_freq=70500000, color="#cc0044", contrast_color="white"),
|
Band(name="4m", start_freq=70000000, end_freq=70500000),
|
||||||
Band(name="2m", start_freq=144000000, end_freq=148000000, color="#FF1493", contrast_color="black"),
|
Band(name="2m", start_freq=144000000, end_freq=148000000),
|
||||||
Band(name="1.25m", start_freq=219000000, end_freq=225000000, color="#CCFF00", contrast_color="black"),
|
Band(name="1.25m", start_freq=219000000, end_freq=225000000),
|
||||||
Band(name="70cm", start_freq=420000000, end_freq=450000000, color="#999900", contrast_color="white"),
|
Band(name="70cm", start_freq=420000000, end_freq=450000000),
|
||||||
Band(name="23cm", start_freq=1240000000, end_freq=1325000000, color="#5AB8C7", contrast_color="black"),
|
Band(name="23cm", start_freq=1240000000, end_freq=1325000000),
|
||||||
Band(name="2.4GHz", start_freq=2300000000, end_freq=2450000000, color="#FF7F50", contrast_color="black"),
|
Band(name="13cm", start_freq=2300000000, end_freq=2450000000),
|
||||||
Band(name="5.8GHz", start_freq=5725000000, end_freq=5850000000, color="#cc0099", contrast_color="white"),
|
Band(name="5.8GHz", start_freq=5725000000, end_freq=5850000000),
|
||||||
Band(name="10GHz", start_freq=10000000000, end_freq=10500000000, color="#696969", contrast_color="white"),
|
Band(name="10GHz", start_freq=10000000000, end_freq=10500000000),
|
||||||
Band(name="24GHz", start_freq=24000000000, end_freq=24050000000, color="#f3edc6", contrast_color="black"),
|
Band(name="24GHz", start_freq=24000000000, end_freq=24050000000),
|
||||||
Band(name="47GHz", start_freq=47000000000, end_freq=47200000000, color="#ffe786", contrast_color="black"),
|
Band(name="47GHz", start_freq=47000000000, end_freq=47200000000),
|
||||||
Band(name="76GHz", start_freq=75500000000, end_freq=81500000000, color="#baf9d8", contrast_color="black")]
|
Band(name="76GHz", start_freq=75500000000, end_freq=81500000000)]
|
||||||
UNKNOWN_BAND = Band(name="Unknown", start_freq=0, end_freq=0, color="black", contrast_color="white")
|
UNKNOWN_BAND = Band(name="Unknown", start_freq=0, end_freq=0)
|
||||||
|
|
||||||
# Continents
|
# Continents
|
||||||
CONTINENTS = ["EU", "NA", "SA", "AS", "AF", "OC", "AN"]
|
CONTINENTS = ["EU", "NA", "SA", "AS", "AF", "OC", "AN"]
|
||||||
|
|||||||
@@ -8,14 +8,6 @@ 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
|
||||||
|
|
||||||
|
|
||||||
# Utility function to get the icon for a named SIG. If no match is found, the "circle-question" icon will be returned.
|
|
||||||
def get_icon_for_sig(sig):
|
|
||||||
for s in SIGS:
|
|
||||||
if s.name == sig:
|
|
||||||
return s.icon
|
|
||||||
return "circle-question"
|
|
||||||
|
|
||||||
|
|
||||||
# Utility function to get the regex string for a SIG reference for a named SIG. If no match is found, None will be returned.
|
# Utility function to get the regex string for a SIG reference for a named SIG. If no match is found, None will be returned.
|
||||||
def get_ref_regex_for_sig(sig):
|
def get_ref_regex_for_sig(sig):
|
||||||
for s in SIGS:
|
for s in SIGS:
|
||||||
@@ -54,6 +46,7 @@ def populate_sig_ref_info(sig_ref):
|
|||||||
sig_ref.grid = data["locator"] if "locator" in data else None
|
sig_ref.grid = data["locator"] if "locator" in data else None
|
||||||
sig_ref.latitude = data["latitude"] if "latitude" in data else None
|
sig_ref.latitude = data["latitude"] if "latitude" in data else None
|
||||||
sig_ref.longitude = data["longitude"] if "longitude" in data else None
|
sig_ref.longitude = data["longitude"] if "longitude" in data else None
|
||||||
|
sig_ref.activation_score = data["points"] if "points" in data else None
|
||||||
elif sig.upper() == "WWBOTA":
|
elif sig.upper() == "WWBOTA":
|
||||||
data = SEMI_STATIC_URL_DATA_CACHE.get("https://api.wwbota.org/bunkers/" + ref_id,
|
data = SEMI_STATIC_URL_DATA_CACHE.get("https://api.wwbota.org/bunkers/" + ref_id,
|
||||||
headers=HTTP_HEADERS).json()
|
headers=HTTP_HEADERS).json()
|
||||||
|
|||||||
@@ -7,7 +7,7 @@ from datetime import datetime, timedelta
|
|||||||
import pytz
|
import pytz
|
||||||
|
|
||||||
from core.lookup_helper import lookup_helper
|
from core.lookup_helper import lookup_helper
|
||||||
from core.sig_utils import get_icon_for_sig, populate_sig_ref_info
|
from core.sig_utils import populate_sig_ref_info
|
||||||
|
|
||||||
|
|
||||||
# Data class that defines an alert.
|
# Data class that defines an alert.
|
||||||
@@ -53,10 +53,6 @@ class Alert:
|
|||||||
sig: str = None
|
sig: str = None
|
||||||
# SIG references. We allow multiple here for e.g. n-fer activations, unlike ADIF SIG_INFO
|
# SIG references. We allow multiple here for e.g. n-fer activations, unlike ADIF SIG_INFO
|
||||||
sig_refs: list = None
|
sig_refs: list = None
|
||||||
# Activation score. SOTA only
|
|
||||||
activation_score: int = None
|
|
||||||
# Icon, from the Font Awesome set. This is fairly opinionated but is here to help the alerthole web UI and Field alertter. Does not include the "fa-" prefix.
|
|
||||||
icon: str = None
|
|
||||||
# Whether this alert is for a DXpedition, as opposed to e.g. an xOTA programme.
|
# Whether this alert is for a DXpedition, as opposed to e.g. an xOTA programme.
|
||||||
is_dxpedition: bool = False
|
is_dxpedition: bool = False
|
||||||
# Where we got the alert from, e.g. "POTA", "SOTA"...
|
# Where we got the alert from, e.g. "POTA", "SOTA"...
|
||||||
@@ -109,10 +105,6 @@ class Alert:
|
|||||||
if self.sig_refs and len(self.sig_refs) > 0 and not self.sig:
|
if self.sig_refs and len(self.sig_refs) > 0 and not self.sig:
|
||||||
self.sig = self.sig_refs[0].sig
|
self.sig = self.sig_refs[0].sig
|
||||||
|
|
||||||
# Icon from SIG
|
|
||||||
if self.sig and not self.icon:
|
|
||||||
self.icon = get_icon_for_sig(self.sig)
|
|
||||||
|
|
||||||
# DX operator details lookup, using QRZ.com. This should be the last resort compared to taking the data from
|
# DX operator details lookup, using QRZ.com. This should be the last resort compared to taking the data from
|
||||||
# the actual alertting service, e.g. we don't want to accidentally use a user's QRZ.com home lat/lon instead of
|
# 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.
|
# the one from the park reference they're at.
|
||||||
|
|||||||
@@ -8,8 +8,4 @@ class Band:
|
|||||||
# Start frequency, in Hz
|
# Start frequency, in Hz
|
||||||
start_freq: float
|
start_freq: float
|
||||||
# Stop frequency, in Hz
|
# Stop frequency, in Hz
|
||||||
end_freq: float
|
end_freq: float
|
||||||
# Colour to use for this band, as per PSK Reporter
|
|
||||||
color: str
|
|
||||||
# Contrast colour to use for text against a background of the band colour
|
|
||||||
contrast_color: str
|
|
||||||
@@ -7,8 +7,5 @@ class SIG:
|
|||||||
name: str
|
name: str
|
||||||
# Description, e.g. "Parks on the Air"
|
# Description, e.g. "Parks on the Air"
|
||||||
description: str
|
description: str
|
||||||
# Icon to use for it, from the Font Awesome set. This is fairly opinionated but is here to help the Spothole web UI
|
|
||||||
# and Field Spotter. Does not include the "fa-" prefix.
|
|
||||||
icon: str
|
|
||||||
# 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
|
||||||
@@ -17,4 +17,6 @@ class SIGRef:
|
|||||||
# Longitude of the reference, if known.
|
# Longitude of the reference, if known.
|
||||||
longitude: float = None
|
longitude: float = None
|
||||||
# Maidenhead grid reference of the reference, if known.
|
# Maidenhead grid reference of the reference, if known.
|
||||||
grid: str = None
|
grid: str = None
|
||||||
|
# Activation score. SOTA only
|
||||||
|
activation_score: int = None
|
||||||
28
data/spot.py
28
data/spot.py
@@ -11,7 +11,7 @@ from pyhamtools.locator import locator_to_latlong, latlong_to_locator
|
|||||||
|
|
||||||
from core.config import MAX_SPOT_AGE
|
from core.config import MAX_SPOT_AGE
|
||||||
from core.lookup_helper import lookup_helper
|
from core.lookup_helper import lookup_helper
|
||||||
from core.sig_utils import get_icon_for_sig, 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
|
||||||
from data.sig_ref import SIGRef
|
from data.sig_ref import SIGRef
|
||||||
|
|
||||||
|
|
||||||
@@ -106,18 +106,6 @@ class Spot:
|
|||||||
sig: str = None
|
sig: str = None
|
||||||
# SIG references. We allow multiple here for e.g. n-fer activations, unlike ADIF SIG_INFO
|
# SIG references. We allow multiple here for e.g. n-fer activations, unlike ADIF SIG_INFO
|
||||||
sig_refs: list = None
|
sig_refs: list = None
|
||||||
# Activation score. SOTA only
|
|
||||||
activation_score: int = None
|
|
||||||
|
|
||||||
# Display guidance (optional)
|
|
||||||
|
|
||||||
# Icon, from the Font Awesome set. This is fairly opinionated but is here to help the Spothole web UI and Field
|
|
||||||
# Spotter. Does not include the "fa-" prefix.
|
|
||||||
icon: str = None
|
|
||||||
# Colour to represent this spot, if a client chooses to colour spots based on their frequency band, using PSK
|
|
||||||
# Reporter's default colours. HTML colour e.g. hex. A contrast colour is also provided which will be black or white.
|
|
||||||
band_color: str = None
|
|
||||||
band_contrast_color: str = None
|
|
||||||
|
|
||||||
# Timing info
|
# Timing info
|
||||||
|
|
||||||
@@ -214,8 +202,6 @@ class Spot:
|
|||||||
if self.freq and not self.band:
|
if self.freq and not self.band:
|
||||||
band = lookup_helper.infer_band_from_freq(self.freq)
|
band = lookup_helper.infer_band_from_freq(self.freq)
|
||||||
self.band = band.name
|
self.band = band.name
|
||||||
self.band_color = band.color
|
|
||||||
self.band_contrast_color = band.contrast_color
|
|
||||||
|
|
||||||
# Mode from comments or bandplan
|
# Mode from comments or bandplan
|
||||||
if self.mode:
|
if self.mode:
|
||||||
@@ -236,8 +222,8 @@ class Spot:
|
|||||||
if self.mode and not self.mode_type:
|
if self.mode and not self.mode_type:
|
||||||
self.mode_type = lookup_helper.infer_mode_type_from_mode(self.mode)
|
self.mode_type = lookup_helper.infer_mode_type_from_mode(self.mode)
|
||||||
|
|
||||||
# If we have a latitude at this point, it can only have been provided by the spot itself
|
# If we have a latitude or grid at this point, it can only have been provided by the spot itself
|
||||||
if self.dx_latitude:
|
if self.dx_latitude or self.dx_grid:
|
||||||
self.dx_location_source = "SPOT"
|
self.dx_location_source = "SPOT"
|
||||||
|
|
||||||
# Set the top-level "SIG" if it is missing but we have at least one SIG ref.
|
# Set the top-level "SIG" if it is missing but we have at least one SIG ref.
|
||||||
@@ -296,14 +282,6 @@ class Spot:
|
|||||||
if self.sig_refs and len(self.sig_refs) > 0 and not self.sig:
|
if self.sig_refs and len(self.sig_refs) > 0 and not self.sig:
|
||||||
self.sig = self.sig_refs[0].sig
|
self.sig = self.sig_refs[0].sig
|
||||||
|
|
||||||
# Icon from SIG if we have one
|
|
||||||
if self.sig:
|
|
||||||
self.icon = get_icon_for_sig(self.sig)
|
|
||||||
|
|
||||||
# Default "radio" icon if nothing else has set it
|
|
||||||
if not self.icon:
|
|
||||||
self.icon = "tower-cell"
|
|
||||||
|
|
||||||
# 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:
|
||||||
ll = locator_to_latlong(self.dx_grid)
|
ll = locator_to_latlong(self.dx_grid)
|
||||||
|
|||||||
18
datafiles/39c3-tota.csv
Normal file
18
datafiles/39c3-tota.csv
Normal file
@@ -0,0 +1,18 @@
|
|||||||
|
ref,lat,lon
|
||||||
|
T-01,53.56278090617755,9.984341869295505
|
||||||
|
T-02,53.562383404176416,9.98551893027115
|
||||||
|
T-03,53.56170184391514,9.985416035619778
|
||||||
|
T-04,53.562026534393176,9.986372919078974
|
||||||
|
T-11,53.56284641242506,9.98475590239655
|
||||||
|
T-12,53.562431705517035,9.98551675702443
|
||||||
|
T-13,53.56223704898424,9.985774520335664
|
||||||
|
T-14,53.5617893512591,9.986344302837976
|
||||||
|
T-21,53.56284641242506,9.98475590239655
|
||||||
|
T-22,53.56245816412497,9.985456089490567
|
||||||
|
T-23,53.56199560857136,9.985636761412673
|
||||||
|
T-24,53.5617893512591,9.986344302837976
|
||||||
|
T-31,53.56247470064887,9.985611427551902
|
||||||
|
T-32,53.5617893512591,9.986344302837976
|
||||||
|
T-41,53.56245039134992,9.985486136112701
|
||||||
|
T-91,53.56147934973529,9.984626806439744
|
||||||
|
T-92,53.561396810300735,9.987553052152899
|
||||||
|
@@ -53,6 +53,11 @@ class APIAlertsStreamHandler(tornado_eventsource.handler.EventSourceHandler):
|
|||||||
self.sse_alert_queues = sse_alert_queues
|
self.sse_alert_queues = sse_alert_queues
|
||||||
self.web_server_metrics = web_server_metrics
|
self.web_server_metrics = web_server_metrics
|
||||||
|
|
||||||
|
# Custom headers to avoid e.g. nginx reverse proxy from buffering SSE data
|
||||||
|
def custom_headers(self):
|
||||||
|
return {"Cache-Control": "no-store",
|
||||||
|
"X-Accel-Buffering": "no"}
|
||||||
|
|
||||||
def open(self):
|
def open(self):
|
||||||
try:
|
try:
|
||||||
# Metrics
|
# Metrics
|
||||||
|
|||||||
@@ -40,6 +40,7 @@ class APIOptionsHandler(tornado.web.RequestHandler):
|
|||||||
# one of our proviers.
|
# one of our proviers.
|
||||||
if ALLOW_SPOTTING:
|
if ALLOW_SPOTTING:
|
||||||
options["spot_sources"].append("API")
|
options["spot_sources"].append("API")
|
||||||
|
options["web-ui-options"]["spot-providers-enabled-by-default"].append("API")
|
||||||
|
|
||||||
self.write(json.dumps(options, default=serialize_everything))
|
self.write(json.dumps(options, default=serialize_everything))
|
||||||
self.set_status(200)
|
self.set_status(200)
|
||||||
|
|||||||
@@ -54,6 +54,11 @@ class APISpotsStreamHandler(tornado_eventsource.handler.EventSourceHandler):
|
|||||||
self.sse_spot_queues = sse_spot_queues
|
self.sse_spot_queues = sse_spot_queues
|
||||||
self.web_server_metrics = web_server_metrics
|
self.web_server_metrics = web_server_metrics
|
||||||
|
|
||||||
|
# Custom headers to avoid e.g. nginx reverse proxy from buffering SSE data
|
||||||
|
def custom_headers(self):
|
||||||
|
return {"Cache-Control": "no-store",
|
||||||
|
"X-Accel-Buffering": "no"}
|
||||||
|
|
||||||
# Called once on the client opening a connection, set things up
|
# Called once on the client opening a connection, set things up
|
||||||
def open(self):
|
def open(self):
|
||||||
try:
|
try:
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import tornado
|
import tornado
|
||||||
from prometheus_client.openmetrics.exposition import CONTENT_TYPE_LATEST
|
from prometheus_client import CONTENT_TYPE_LATEST
|
||||||
|
|
||||||
from core.prometheus_metrics_handler import get_metrics
|
from core.prometheus_metrics_handler import get_metrics
|
||||||
|
|
||||||
|
|||||||
@@ -51,7 +51,6 @@ class APRSIS(SpotProvider):
|
|||||||
comment=data["comment"] if "comment" in data else None,
|
comment=data["comment"] if "comment" in data else None,
|
||||||
dx_latitude=data["latitude"] if "latitude" in data else None,
|
dx_latitude=data["latitude"] if "latitude" in data else None,
|
||||||
dx_longitude=data["longitude"] if "longitude" in data else None,
|
dx_longitude=data["longitude"] if "longitude" in data else None,
|
||||||
icon="tower-cell",
|
|
||||||
time=datetime.now(pytz.UTC).timestamp()) # APRS-IS spots are live so we can assume spot time is "now"
|
time=datetime.now(pytz.UTC).timestamp()) # APRS-IS spots are live so we can assume spot time is "now"
|
||||||
|
|
||||||
# Add to our list
|
# Add to our list
|
||||||
|
|||||||
@@ -12,22 +12,27 @@ from data.spot import Spot
|
|||||||
from spotproviders.spot_provider import SpotProvider
|
from spotproviders.spot_provider import SpotProvider
|
||||||
|
|
||||||
|
|
||||||
# Spot provider for a DX Cluster. Hostname port and login_prompt provided as parameters.
|
# Spot provider for a DX Cluster. Hostname, port, login_prompt, login_callsign and allow_rbn_spots are provided in config.
|
||||||
|
# See config-example.yml for examples.
|
||||||
class DXCluster(SpotProvider):
|
class DXCluster(SpotProvider):
|
||||||
# Note the callsign pattern deliberately excludes calls ending in "-#", which are from RBN and can be enabled by
|
|
||||||
# default on some clusters. If you want RBN spots, there is a separate provider for that.
|
|
||||||
CALLSIGN_PATTERN = "([a-z|0-9|/]+)"
|
CALLSIGN_PATTERN = "([a-z|0-9|/]+)"
|
||||||
FREQUENCY_PATTERN = "([0-9|.]+)"
|
FREQUENCY_PATTERN = "([0-9|.]+)"
|
||||||
LINE_PATTERN = re.compile(
|
LINE_PATTERN_EXCLUDE_RBN = re.compile(
|
||||||
"^DX de " + CALLSIGN_PATTERN + ":\\s+" + FREQUENCY_PATTERN + "\\s+" + CALLSIGN_PATTERN + "\\s+(.*)\\s+(\\d{4}Z)",
|
"^DX de " + CALLSIGN_PATTERN + ":\\s+" + FREQUENCY_PATTERN + "\\s+" + CALLSIGN_PATTERN + "\\s+(.*)\\s+(\\d{4}Z)",
|
||||||
re.IGNORECASE)
|
re.IGNORECASE)
|
||||||
|
LINE_PATTERN_ALLOW_RBN = re.compile(
|
||||||
|
"^DX de " + CALLSIGN_PATTERN + "-?#?:\\s+" + FREQUENCY_PATTERN + "\\s+" + CALLSIGN_PATTERN + "\\s+(.*)\\s+(\\d{4}Z)",
|
||||||
|
re.IGNORECASE)
|
||||||
|
|
||||||
# Constructor requires hostname and port
|
# Constructor requires hostname and port
|
||||||
def __init__(self, provider_config):
|
def __init__(self, provider_config):
|
||||||
super().__init__(provider_config)
|
super().__init__(provider_config)
|
||||||
self.hostname = provider_config["host"]
|
self.hostname = provider_config["host"]
|
||||||
self.port = provider_config["port"]
|
self.port = provider_config["port"]
|
||||||
self.login_prompt = provider_config["login_prompt"]
|
self.login_prompt = provider_config["login_prompt"] if "login_prompt" in provider_config else "login:"
|
||||||
|
self.login_callsign = provider_config["login_callsign"] if "login_callsign" in provider_config else SERVER_OWNER_CALLSIGN
|
||||||
|
self.allow_rbn_spots = provider_config["allow_rbn_spots"] if "allow_rbn_spots" in provider_config else False
|
||||||
|
self.spot_line_pattern = self.LINE_PATTERN_ALLOW_RBN if self.allow_rbn_spots else self.LINE_PATTERN_EXCLUDE_RBN
|
||||||
self.telnet = None
|
self.telnet = None
|
||||||
self.thread = Thread(target=self.handle)
|
self.thread = Thread(target=self.handle)
|
||||||
self.thread.daemon = True
|
self.thread.daemon = True
|
||||||
@@ -50,7 +55,7 @@ class DXCluster(SpotProvider):
|
|||||||
logging.info("DX Cluster " + self.hostname + " connecting...")
|
logging.info("DX Cluster " + self.hostname + " connecting...")
|
||||||
self.telnet = telnetlib3.Telnet(self.hostname, self.port)
|
self.telnet = telnetlib3.Telnet(self.hostname, self.port)
|
||||||
self.telnet.read_until(self.login_prompt.encode("latin-1"))
|
self.telnet.read_until(self.login_prompt.encode("latin-1"))
|
||||||
self.telnet.write((SERVER_OWNER_CALLSIGN + "\n").encode("latin-1"))
|
self.telnet.write((self.login_callsign + "\n").encode("latin-1"))
|
||||||
connected = True
|
connected = True
|
||||||
logging.info("DX Cluster " + self.hostname + " connected.")
|
logging.info("DX Cluster " + self.hostname + " connected.")
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
@@ -63,7 +68,7 @@ class DXCluster(SpotProvider):
|
|||||||
try:
|
try:
|
||||||
# Check new telnet info against regular expression
|
# Check new telnet info against regular expression
|
||||||
telnet_output = self.telnet.read_until("\n".encode("latin-1"))
|
telnet_output = self.telnet.read_until("\n".encode("latin-1"))
|
||||||
match = self.LINE_PATTERN.match(telnet_output.decode("latin-1"))
|
match = self.spot_line_pattern.match(telnet_output.decode("latin-1"))
|
||||||
if match:
|
if match:
|
||||||
spot_time = datetime.strptime(match.group(5), "%H%MZ")
|
spot_time = datetime.strptime(match.group(5), "%H%MZ")
|
||||||
spot_datetime = datetime.combine(datetime.today(), spot_time.time()).replace(tzinfo=pytz.UTC)
|
spot_datetime = datetime.combine(datetime.today(), spot_time.time()).replace(tzinfo=pytz.UTC)
|
||||||
@@ -72,7 +77,6 @@ class DXCluster(SpotProvider):
|
|||||||
de_call=match.group(1),
|
de_call=match.group(1),
|
||||||
freq=float(match.group(2)) * 1000,
|
freq=float(match.group(2)) * 1000,
|
||||||
comment=match.group(4).strip(),
|
comment=match.group(4).strip(),
|
||||||
icon="tower-cell",
|
|
||||||
time=spot_datetime.timestamp())
|
time=spot_datetime.timestamp())
|
||||||
|
|
||||||
# Add to our list
|
# Add to our list
|
||||||
|
|||||||
@@ -70,7 +70,6 @@ class RBN(SpotProvider):
|
|||||||
de_call=match.group(1),
|
de_call=match.group(1),
|
||||||
freq=float(match.group(2)) * 1000,
|
freq=float(match.group(2)) * 1000,
|
||||||
comment=match.group(4).strip(),
|
comment=match.group(4).strip(),
|
||||||
icon="tower-cell",
|
|
||||||
time=spot_datetime.timestamp())
|
time=spot_datetime.timestamp())
|
||||||
|
|
||||||
# Add to our list
|
# Add to our list
|
||||||
|
|||||||
@@ -45,9 +45,8 @@ class SOTA(HTTPSpotProvider):
|
|||||||
mode=source_spot["mode"].upper(),
|
mode=source_spot["mode"].upper(),
|
||||||
comment=source_spot["comments"],
|
comment=source_spot["comments"],
|
||||||
sig="SOTA",
|
sig="SOTA",
|
||||||
sig_refs=[SIGRef(id=source_spot["summitCode"], sig="SOTA", name=source_spot["summitName"])],
|
sig_refs=[SIGRef(id=source_spot["summitCode"], sig="SOTA", name=source_spot["summitName"], activation_score=source_spot["points"])],
|
||||||
time=datetime.fromisoformat(source_spot["timeStamp"]).timestamp(),
|
time=datetime.fromisoformat(source_spot["timeStamp"]).timestamp())
|
||||||
activation_score=source_spot["points"])
|
|
||||||
|
|
||||||
# Add to our list. Don't worry about de-duping, removing old spots etc. at this point; other code will do
|
# Add to our list. Don't worry about de-duping, removing old spots etc. at this point; other code will do
|
||||||
# that for us.
|
# that for us.
|
||||||
|
|||||||
@@ -61,7 +61,6 @@ class UKPacketNet(HTTPSpotProvider):
|
|||||||
freq=freq,
|
freq=freq,
|
||||||
mode="PKT",
|
mode="PKT",
|
||||||
comment=comment,
|
comment=comment,
|
||||||
icon="tower-cell",
|
|
||||||
time=datetime.strptime(heard["lastHeard"], "%Y-%m-%d %H:%M:%S").replace(tzinfo=pytz.UTC).timestamp(),
|
time=datetime.strptime(heard["lastHeard"], "%Y-%m-%d %H:%M:%S").replace(tzinfo=pytz.UTC).timestamp(),
|
||||||
de_grid=node["location"]["locator"] if "locator" in node["location"] else None,
|
de_grid=node["location"]["locator"] if "locator" in node["location"] else None,
|
||||||
de_latitude=node["location"]["coords"]["lat"],
|
de_latitude=node["location"]["coords"]["lat"],
|
||||||
|
|||||||
@@ -1,4 +1,6 @@
|
|||||||
|
import csv
|
||||||
import json
|
import json
|
||||||
|
import logging
|
||||||
from datetime import datetime
|
from datetime import datetime
|
||||||
|
|
||||||
import pytz
|
import pytz
|
||||||
@@ -9,31 +11,45 @@ from spotproviders.websocket_spot_provider import WebsocketSpotProvider
|
|||||||
|
|
||||||
|
|
||||||
# Spot provider for servers based on the "xOTA" software at https://github.com/nischu/xOTA/
|
# Spot provider for servers based on the "xOTA" software at https://github.com/nischu/xOTA/
|
||||||
# The provider typically doesn't give us a lat/lon or SIG explicitly, so our own config provides this information. This
|
# The provider typically doesn't give us a lat/lon or SIG explicitly, so our own config provides a SIG and a reference
|
||||||
# functionality is implemented for TOTA events.
|
# to a local CSV file with location information. This functionality is implemented for TOTA events, of which there are
|
||||||
|
# several - so a plain lookup of a "TOTA reference" doesn't make sense, it depends on which TOTA and hence which server
|
||||||
|
# supplied the data, which is why the CSV location lookup is here and not in sig_utils.
|
||||||
class XOTA(WebsocketSpotProvider):
|
class XOTA(WebsocketSpotProvider):
|
||||||
FIXED_LATITUDE = None
|
LOCATION_DATA = {}
|
||||||
FIXED_LONGITUDE = None
|
|
||||||
SIG = None
|
SIG = None
|
||||||
|
|
||||||
def __init__(self, provider_config):
|
def __init__(self, provider_config):
|
||||||
super().__init__(provider_config, provider_config["url"])
|
super().__init__(provider_config, provider_config["url"])
|
||||||
self.FIXED_LATITUDE = provider_config["latitude"] if "latitude" in provider_config else None
|
locations_csv = provider_config["locations-csv"] if "locations-csv" in provider_config else None
|
||||||
self.FIXED_LONGITUDE = provider_config["longitude"] if "longitude" in provider_config else None
|
|
||||||
self.SIG = provider_config["sig"] if "sig" in provider_config else None
|
self.SIG = provider_config["sig"] if "sig" in provider_config else None
|
||||||
|
|
||||||
|
# Load location data
|
||||||
|
if locations_csv:
|
||||||
|
try:
|
||||||
|
f = open(locations_csv)
|
||||||
|
csv_data = f.read()
|
||||||
|
dr = csv.DictReader(csv_data.splitlines())
|
||||||
|
for row in dr:
|
||||||
|
self.LOCATION_DATA[row["ref"]] = {"lat": row["lat"], "lon": row["lon"]}
|
||||||
|
except:
|
||||||
|
logging.exception("Could not look up location data for XOTA source.")
|
||||||
|
|
||||||
def ws_message_to_spot(self, bytes):
|
def ws_message_to_spot(self, bytes):
|
||||||
string = bytes.decode("utf-8")
|
string = bytes.decode("utf-8")
|
||||||
source_spot = json.loads(string)
|
source_spot = json.loads(string)
|
||||||
|
ref_id = source_spot["reference"]["title"]
|
||||||
|
lat = float(self.LOCATION_DATA[ref_id]["lat"]) if ref_id in self.LOCATION_DATA else None
|
||||||
|
lon = float(self.LOCATION_DATA[ref_id]["lon"]) if ref_id in self.LOCATION_DATA else None
|
||||||
spot = Spot(source=self.name,
|
spot = Spot(source=self.name,
|
||||||
source_id=source_spot["id"],
|
source_id=source_spot["id"],
|
||||||
dx_call=source_spot["stationCallSign"].upper(),
|
dx_call=source_spot["stationCallSign"].upper(),
|
||||||
freq=float(source_spot["freq"]) * 1000,
|
freq=float(source_spot["freq"]) * 1000,
|
||||||
mode=source_spot["mode"].upper(),
|
mode=source_spot["mode"].upper(),
|
||||||
sig=self.SIG,
|
sig=self.SIG,
|
||||||
sig_refs=[SIGRef(id=source_spot["reference"]["title"], sig=self.SIG, url=source_spot["reference"]["website"])],
|
sig_refs=[SIGRef(id=ref_id, sig=self.SIG, url=source_spot["reference"]["website"], latitude=lat, longitude=lon)],
|
||||||
time=datetime.now(pytz.UTC).timestamp(),
|
time=datetime.now(pytz.UTC).timestamp(),
|
||||||
dx_latitude=self.FIXED_LATITUDE,
|
dx_latitude=lat,
|
||||||
dx_longitude=self.FIXED_LONGITUDE,
|
dx_longitude=lon,
|
||||||
qrt=source_spot["state"] != "active")
|
qrt=source_spot["state"] != "active")
|
||||||
return spot
|
return spot
|
||||||
|
|||||||
@@ -63,7 +63,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>
|
<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>
|
</div>
|
||||||
|
|
||||||
<script src="/js/common.js?v=2"></script>
|
<script src="/js/common.js?v=6"></script>
|
||||||
<script>$(document).ready(function() { $("#nav-link-about").addClass("active"); }); <!-- highlight active page in nav --></script>
|
<script>$(document).ready(function() { $("#nav-link-about").addClass("active"); }); <!-- highlight active page in nav --></script>
|
||||||
|
|
||||||
{% end %}
|
{% end %}
|
||||||
@@ -69,8 +69,8 @@
|
|||||||
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<script src="/js/common.js?v=2"></script>
|
<script src="/js/common.js?v=6"></script>
|
||||||
<script src="/js/add-spot.js?v=2"></script>
|
<script src="/js/add-spot.js?v=6"></script>
|
||||||
<script>$(document).ready(function() { $("#nav-link-add-spot").addClass("active"); }); <!-- highlight active page in nav --></script>
|
<script>$(document).ready(function() { $("#nav-link-add-spot").addClass("active"); }); <!-- highlight active page in nav --></script>
|
||||||
|
|
||||||
{% end %}
|
{% end %}
|
||||||
@@ -168,8 +168,8 @@
|
|||||||
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<script src="/js/common.js?v=2"></script>
|
<script src="/js/common.js?v=6"></script>
|
||||||
<script src="/js/alerts.js?v=2"></script>
|
<script src="/js/alerts.js?v=6"></script>
|
||||||
<script>$(document).ready(function() { $("#nav-link-alerts").addClass("active"); }); <!-- highlight active page in nav --></script>
|
<script>$(document).ready(function() { $("#nav-link-alerts").addClass("active"); }); <!-- highlight active page in nav --></script>
|
||||||
|
|
||||||
{% end %}
|
{% end %}
|
||||||
@@ -117,6 +117,11 @@
|
|||||||
<input class="form-check-input storeable-checkbox" type="checkbox" id="darkMode" value="darkMode" oninput="toggleDarkMode();">
|
<input class="form-check-input storeable-checkbox" type="checkbox" id="darkMode" value="darkMode" oninput="toggleDarkMode();">
|
||||||
<label class="form-check-label" for="darkMode">Dark mode</label>
|
<label class="form-check-label" for="darkMode">Dark mode</label>
|
||||||
</div>
|
</div>
|
||||||
|
<p class="card-text spothole-card-text">
|
||||||
|
Band color scheme<br/>
|
||||||
|
<select id="band-color-scheme" class="storeable-select form-select d-inline-block" oninput="setBandColorSchemeFromUI();" style="display: inline-block;">
|
||||||
|
</select>
|
||||||
|
</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -129,9 +134,9 @@
|
|||||||
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<script src="/js/common.js?v=2"></script>
|
<script src="/js/common.js?v=6"></script>
|
||||||
<script src="/js/spotsbandsandmap.js?v=2"></script>
|
<script src="/js/spotsbandsandmap.js?v=6"></script>
|
||||||
<script src="/js/bands.js?v=2"></script>
|
<script src="/js/bands.js?v=6"></script>
|
||||||
<script>$(document).ready(function() { $("#nav-link-bands").addClass("active"); }); <!-- highlight active page in nav --></script>
|
<script>$(document).ready(function() { $("#nav-link-bands").addClass("active"); }); <!-- highlight active page in nav --></script>
|
||||||
|
|
||||||
{% end %}
|
{% end %}
|
||||||
@@ -44,6 +44,12 @@
|
|||||||
<script src="https://cdn.jsdelivr.net/npm/bootstrap@5.3.8/dist/js/bootstrap.bundle.min.js"
|
<script src="https://cdn.jsdelivr.net/npm/bootstrap@5.3.8/dist/js/bootstrap.bundle.min.js"
|
||||||
integrity="sha384-FKyoEForCGlyvwx9Hj09JcYn3nv7wiPVlz7YYwJrWVcXK/BmnVDxM+D2scQbITxI"
|
integrity="sha384-FKyoEForCGlyvwx9Hj09JcYn3nv7wiPVlz7YYwJrWVcXK/BmnVDxM+D2scQbITxI"
|
||||||
crossorigin="anonymous"></script>
|
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=6"></script>
|
||||||
|
<script src="https://misc.ianrenton.com/jsutils/storage.js?v=6"></script>
|
||||||
|
<script src="https://misc.ianrenton.com/jsutils/ui-ham.js?v=6"></script>
|
||||||
|
<script src="https://misc.ianrenton.com/jsutils/geo.js?v=6"></script>
|
||||||
|
|
||||||
</head>
|
</head>
|
||||||
<body>
|
<body>
|
||||||
|
|||||||
@@ -129,6 +129,11 @@
|
|||||||
<input class="form-check-input storeable-checkbox" type="checkbox" id="darkMode" value="darkMode" oninput="toggleDarkMode();">
|
<input class="form-check-input storeable-checkbox" type="checkbox" id="darkMode" value="darkMode" oninput="toggleDarkMode();">
|
||||||
<label class="form-check-label" for="darkMode">Dark mode</label>
|
<label class="form-check-label" for="darkMode">Dark mode</label>
|
||||||
</div>
|
</div>
|
||||||
|
<p class="card-text spothole-card-text">
|
||||||
|
Band color scheme<br/>
|
||||||
|
<select id="band-color-scheme" class="storeable-select form-select d-inline-block" oninput="setBandColorSchemeFromUI();" style="display: inline-block;">
|
||||||
|
</select>
|
||||||
|
</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -147,9 +152,9 @@
|
|||||||
<script src="https://cdn.jsdelivr.net/npm/leaflet.geodesic"></script>
|
<script src="https://cdn.jsdelivr.net/npm/leaflet.geodesic"></script>
|
||||||
<script src="https://cdn.jsdelivr.net/npm/@joergdietrich/leaflet.terminator@1.1.0/L.Terminator.min.js"></script>
|
<script src="https://cdn.jsdelivr.net/npm/@joergdietrich/leaflet.terminator@1.1.0/L.Terminator.min.js"></script>
|
||||||
|
|
||||||
<script src="/js/common.js?v=2"></script>
|
<script src="/js/common.js?v=6"></script>
|
||||||
<script src="/js/spotsbandsandmap.js?v=2"></script>
|
<script src="/js/spotsbandsandmap.js?v=6"></script>
|
||||||
<script src="/js/map.js?v=2"></script>
|
<script src="/js/map.js?v=6"></script>
|
||||||
<script>$(document).ready(function() { $("#nav-link-map").addClass("active"); }); <!-- highlight active page in nav --></script>
|
<script>$(document).ready(function() { $("#nav-link-map").addClass("active"); }); <!-- highlight active page in nav --></script>
|
||||||
|
|
||||||
{% end %}
|
{% end %}
|
||||||
@@ -154,12 +154,17 @@
|
|||||||
<div class="card">
|
<div class="card">
|
||||||
<div class="card-body">
|
<div class="card-body">
|
||||||
<h5 class="card-title">Theme</h5>
|
<h5 class="card-title">Theme</h5>
|
||||||
<div class="form-group">
|
<div class="form-group">
|
||||||
<div class="form-check form-check-inline">
|
<div class="form-check form-check-inline">
|
||||||
<input class="form-check-input storeable-checkbox" type="checkbox" id="darkMode" value="darkMode" oninput="toggleDarkMode();">
|
<input class="form-check-input storeable-checkbox" type="checkbox" id="darkMode" value="darkMode" oninput="toggleDarkMode();">
|
||||||
<label class="form-check-label" for="darkMode">Dark mode</label>
|
<label class="form-check-label" for="darkMode">Dark mode</label>
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
|
</div>
|
||||||
|
<p class="card-text spothole-card-text">
|
||||||
|
Band color scheme<br/>
|
||||||
|
<select id="band-color-scheme" class="storeable-select form-select d-inline-block" oninput="setBandColorSchemeFromUI();" style="display: inline-block;">
|
||||||
|
</select>
|
||||||
|
</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -218,9 +223,9 @@
|
|||||||
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<script src="/js/common.js?v=2"></script>
|
<script src="/js/common.js?v=6"></script>
|
||||||
<script src="/js/spotsbandsandmap.js?v=2"></script>
|
<script src="/js/spotsbandsandmap.js?v=6"></script>
|
||||||
<script src="/js/spots.js?v=2"></script>
|
<script src="/js/spots.js?v=6"></script>
|
||||||
<script>$(document).ready(function() { $("#nav-link-spots").addClass("active"); }); <!-- highlight active page in nav --></script>
|
<script>$(document).ready(function() { $("#nav-link-spots").addClass("active"); }); <!-- highlight active page in nav --></script>
|
||||||
|
|
||||||
{% end %}
|
{% end %}
|
||||||
@@ -3,8 +3,8 @@
|
|||||||
|
|
||||||
<div id="status-container" class="row row-cols-1 row-cols-md-4 g-4 mt-4"></div>
|
<div id="status-container" class="row row-cols-1 row-cols-md-4 g-4 mt-4"></div>
|
||||||
|
|
||||||
<script src="/js/common.js?v=2"></script>
|
<script src="/js/common.js?v=6"></script>
|
||||||
<script src="/js/status.js?v=2"></script>
|
<script src="/js/status.js?v=6"></script>
|
||||||
<script>$(document).ready(function() { $("#nav-link-status").addClass("active"); }); <!-- highlight active page in nav --></script>
|
<script>$(document).ready(function() { $("#nav-link-status").addClass("active"); }); <!-- highlight active page in nav --></script>
|
||||||
|
|
||||||
{% end %}
|
{% end %}
|
||||||
10
webassets/.idea/.gitignore
generated
vendored
Normal file
10
webassets/.idea/.gitignore
generated
vendored
Normal file
@@ -0,0 +1,10 @@
|
|||||||
|
# Default ignored files
|
||||||
|
/shelf/
|
||||||
|
/workspace.xml
|
||||||
|
# Ignored default folder with query files
|
||||||
|
/queries/
|
||||||
|
# Datasource local storage ignored files
|
||||||
|
/dataSources/
|
||||||
|
/dataSources.local.xml
|
||||||
|
# Editor-based HTTP Client requests
|
||||||
|
/httpRequests/
|
||||||
6
webassets/.idea/vcs.xml
generated
Normal file
6
webassets/.idea/vcs.xml
generated
Normal file
@@ -0,0 +1,6 @@
|
|||||||
|
<?xml version="1.0" encoding="UTF-8"?>
|
||||||
|
<project version="4">
|
||||||
|
<component name="VcsDirectoryMappings">
|
||||||
|
<mapping directory="$PROJECT_DIR$/.." vcs="Git" />
|
||||||
|
</component>
|
||||||
|
</project>
|
||||||
@@ -1,4 +1,4 @@
|
|||||||
openapi: 3.0.4
|
openapi: 3.1.0
|
||||||
info:
|
info:
|
||||||
title: Spothole API
|
title: Spothole API
|
||||||
description: |-
|
description: |-
|
||||||
@@ -9,12 +9,20 @@ info:
|
|||||||
The API calls described below allow third-party software to access data from Spothole, and receive data on spots and alerts in a consistent format regardless of the data sources used by Spothole itself. Utility calls are also provided for general data lookups.
|
The API calls described below allow third-party software to access data from Spothole, and receive data on spots and alerts in a consistent format regardless of the data sources used by Spothole itself. Utility calls are also provided for general data lookups.
|
||||||
|
|
||||||
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, and there are plenty of areas where Spothole's location data may be inaccurate. 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.
|
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, and there are plenty of areas where Spothole's location data may be inaccurate. 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.
|
||||||
|
|
||||||
|
## Changelog
|
||||||
|
|
||||||
|
### 1.1
|
||||||
|
|
||||||
|
* Added Server-Sent Event API endpoints for spots and alerts.
|
||||||
|
* Removed band colour and icon information from spots.
|
||||||
|
* Moved activation_score from top-level in Spot and Alert to be part of the SIGRef
|
||||||
contact:
|
contact:
|
||||||
email: ian@ianrenton.com
|
email: ian@ianrenton.com
|
||||||
license:
|
license:
|
||||||
name: The Unlicense
|
name: The Unlicense
|
||||||
url: https://unlicense.org/#the-unlicense
|
url: https://unlicense.org/#the-unlicense
|
||||||
version: v1
|
version: v1.1
|
||||||
servers:
|
servers:
|
||||||
- url: https://spothole.app/api/v1
|
- url: https://spothole.app/api/v1
|
||||||
paths:
|
paths:
|
||||||
@@ -549,6 +557,12 @@ paths:
|
|||||||
type: integer
|
type: integer
|
||||||
example: 30
|
example: 30
|
||||||
description: The suggested default "maximum spot age" that the web UI should retrieve from the API
|
description: The suggested default "maximum spot age" that the web UI should retrieve from the API
|
||||||
|
spot-providers-enabled-by-default:
|
||||||
|
type: array
|
||||||
|
description: A list of the spot providers that should be enabled in the web UI on first load, if the user hasn't already got a localStorage setting that sets their preference. This is to allow some high-volume providers like RBN to be enabled in Spothole's back-end and displayable in the web UI if the user wants, but by default the experience will not include them.
|
||||||
|
items:
|
||||||
|
type: string
|
||||||
|
example: "POTA"
|
||||||
alert-count:
|
alert-count:
|
||||||
type: array
|
type: array
|
||||||
description: An array of suggested "alert counts" that the web UI can retrieve from the API
|
description: An array of suggested "alert counts" that the web UI can retrieve from the API
|
||||||
@@ -934,6 +948,10 @@ components:
|
|||||||
type: number
|
type: number
|
||||||
description: Longitude of the reference, in degrees, if known.
|
description: Longitude of the reference, in degrees, if known.
|
||||||
example: -1.2345
|
example: -1.2345
|
||||||
|
activation_score:
|
||||||
|
type: integer
|
||||||
|
description: Activation score. SOTA only
|
||||||
|
example: 0
|
||||||
|
|
||||||
Spot:
|
Spot:
|
||||||
type: object
|
type: object
|
||||||
@@ -1080,22 +1098,6 @@ components:
|
|||||||
items:
|
items:
|
||||||
$ref: '#/components/schemas/SIGRef'
|
$ref: '#/components/schemas/SIGRef'
|
||||||
description: SIG references. We allow multiple here for e.g. n-fer activations, unlike ADIF SIG_INFO
|
description: SIG references. We allow multiple here for e.g. n-fer activations, unlike ADIF SIG_INFO
|
||||||
activation_score:
|
|
||||||
type: integer
|
|
||||||
description: Activation score. SOTA only
|
|
||||||
example: 0
|
|
||||||
icon:
|
|
||||||
type: string
|
|
||||||
descripton: Icon, from the Font Awesome set. This is fairly opinionated but is here to help the Spothole web UI and Field Spotter. Does not include the "fa-" prefix.
|
|
||||||
example: tree
|
|
||||||
band_color:
|
|
||||||
type: string
|
|
||||||
descripton: Colour to represent this spot, if a client chooses to colour spots based on their frequency band, using PSK Reporter's default colours. HTML colour e.g. hex.
|
|
||||||
example: "#ff0000"
|
|
||||||
band_contrast_color:
|
|
||||||
type: string
|
|
||||||
descripton: Black or white, whichever best contrasts with "band_color".
|
|
||||||
example: "white"
|
|
||||||
qrt:
|
qrt:
|
||||||
type: boolean
|
type: boolean
|
||||||
description: QRT state. Some APIs return spots marked as QRT. Otherwise we can check the comments.
|
description: QRT state. Some APIs return spots marked as QRT. Otherwise we can check the comments.
|
||||||
@@ -1200,14 +1202,6 @@ components:
|
|||||||
items:
|
items:
|
||||||
$ref: '#/components/schemas/SIGRef'
|
$ref: '#/components/schemas/SIGRef'
|
||||||
description: SIG references. We allow multiple here for e.g. n-fer activations, unlike ADIF SIG_INFO
|
description: SIG references. We allow multiple here for e.g. n-fer activations, unlike ADIF SIG_INFO
|
||||||
activation_score:
|
|
||||||
type: integer
|
|
||||||
description: Activation score. SOTA only
|
|
||||||
example: 0
|
|
||||||
icon:
|
|
||||||
type: string
|
|
||||||
descripton: Icon, from the Font Awesome set. This is fairly opinionated but is here to help the Spothole web UI and Field Spotter. Does not include the "fa-" prefix.
|
|
||||||
example: tree
|
|
||||||
source:
|
source:
|
||||||
type: string
|
type: string
|
||||||
description: Where we got the alert from.
|
description: Where we got the alert from.
|
||||||
@@ -1283,14 +1277,6 @@ components:
|
|||||||
type: int
|
type: int
|
||||||
description: The end frequency of this band, in Hz.
|
description: The end frequency of this band, in Hz.
|
||||||
example: 7200000
|
example: 7200000
|
||||||
color:
|
|
||||||
type: string
|
|
||||||
description: The color associated with this mode, as used on PSK Reporter.
|
|
||||||
example: "#5959ff"
|
|
||||||
contrast_color:
|
|
||||||
type: string
|
|
||||||
description: Black or white, whichever provides the best contrast against the band colour.
|
|
||||||
example: white
|
|
||||||
|
|
||||||
SIG:
|
SIG:
|
||||||
type: object
|
type: object
|
||||||
@@ -1302,10 +1288,6 @@ 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
|
||||||
icon:
|
|
||||||
type: string
|
|
||||||
description: Icon, from the Font Awesome set. This is fairly opinionated but is here to help the Spothole web UI and Field Spotter. Does not include the "fa-" prefix.
|
|
||||||
example: tree
|
|
||||||
ref_regex:
|
ref_regex:
|
||||||
type: string
|
type: string
|
||||||
description: Regex that matches this SIG's reference IDs. Generally for Spothole's own internal use, clients probably won't need this.
|
description: Regex that matches this SIG's reference IDs. Generally for Spothole's own internal use, clients probably won't need this.
|
||||||
|
|||||||
@@ -224,6 +224,10 @@ div#map {
|
|||||||
filter: invert(100%) hue-rotate(180deg) brightness(95%) contrast(90%);
|
filter: invert(100%) hue-rotate(180deg) brightness(95%) contrast(90%);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* Make buttons overlaid on the map have a non-transparent fill so you can see the text better */
|
||||||
|
.btn-outline-primary {
|
||||||
|
--bs-btn-bg: var(--bs-body-bg) !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
/* BANDS PANEL */
|
/* BANDS PANEL */
|
||||||
|
|||||||
@@ -243,7 +243,7 @@ function addAlertRowsToTable(tbody, alerts) {
|
|||||||
$tr.append(`<td class='hideonmobile'>${commentText}</td>`);
|
$tr.append(`<td class='hideonmobile'>${commentText}</td>`);
|
||||||
}
|
}
|
||||||
if (showSource) {
|
if (showSource) {
|
||||||
$tr.append(`<td class='nowrap hideonmobile'><span class='icon-wrapper'><i class='fa-solid fa-${a["icon"]}'></i></span> ${sigSourceText}</td>`);
|
$tr.append(`<td class='nowrap hideonmobile'><span class='icon-wrapper'><i class='fa-solid ${sigToIcon(a["sig"], "fa-globe-africa")}'></i></span> ${sigSourceText}</td>`);
|
||||||
}
|
}
|
||||||
if (showRef) {
|
if (showRef) {
|
||||||
$tr.append(`<td class='hideonmobile'>${sig_refs}</td>`);
|
$tr.append(`<td class='hideonmobile'>${sig_refs}</td>`);
|
||||||
@@ -257,7 +257,7 @@ function addAlertRowsToTable(tbody, alerts) {
|
|||||||
}
|
}
|
||||||
$td2 = $("<td colspan='100'>");
|
$td2 = $("<td colspan='100'>");
|
||||||
if (showSource) {
|
if (showSource) {
|
||||||
$td2.append(`<span class='icon-wrapper'><i class='fa-solid fa-${a["icon"]}'></i></span> `);
|
$td2.append(`<span class='icon-wrapper'><i class='fa-solid ${sigToIcon(a["sig"], "fa-globe-africa")}'></i></span> `);
|
||||||
}
|
}
|
||||||
if (showRef) {
|
if (showRef) {
|
||||||
$td2.append(`${sig_refs} `);
|
$td2.append(`${sig_refs} `);
|
||||||
@@ -351,4 +351,12 @@ $(document).ready(function() {
|
|||||||
loadOptions();
|
loadOptions();
|
||||||
// Update the refresh timing display every second
|
// Update the refresh timing display every second
|
||||||
setInterval(updateRefreshDisplay, 1000);
|
setInterval(updateRefreshDisplay, 1000);
|
||||||
|
});
|
||||||
|
|
||||||
|
// Reload alerts on becoming visible. This forces a refresh when used as a PWA and the user switches back to the PWA
|
||||||
|
// after some time has passed with it in the background.
|
||||||
|
addEventListener("visibilitychange", (event) => {
|
||||||
|
if (!document.hidden) {
|
||||||
|
loadAlerts();
|
||||||
|
}
|
||||||
});
|
});
|
||||||
@@ -70,7 +70,7 @@ function updateBands() {
|
|||||||
var table = $('<table id="bands-table">').append('<thead><tr></tr></thead><tbody><tr></tr></tbody>');
|
var table = $('<table id="bands-table">').append('<thead><tr></tr></thead><tbody><tr></tr></tbody>');
|
||||||
bandToSpots.forEach(function (spotList, bandName) {
|
bandToSpots.forEach(function (spotList, bandName) {
|
||||||
// Get the colours for the band from the first spot, and prepare the header
|
// Get the colours for the band from the first spot, and prepare the header
|
||||||
table.find('thead tr').append(`<th style='background-color:${spotList[0].band_color}; color:${spotList[0].band_contrast_color}'>${spotList[0].band}</th>`);
|
table.find('thead tr').append(`<th style='background-color:${bandToColor(spotList[0].band)}; color:${bandToContrastColor(spotList[0].band)}'>${spotList[0].band}</th>`);
|
||||||
|
|
||||||
// Get the band data to fetch start and end frequencies
|
// Get the band data to fetch start and end frequencies
|
||||||
let band = options["bands"].filter(function (b) {
|
let band = options["bands"].filter(function (b) {
|
||||||
@@ -145,7 +145,7 @@ function updateBands() {
|
|||||||
|
|
||||||
// Now each spot is tagged with how far down the div it should go, add them to the DOM.
|
// Now each spot is tagged with how far down the div it should go, add them to the DOM.
|
||||||
spotList.forEach(s => {
|
spotList.forEach(s => {
|
||||||
bandSpotsDiv.append(`<div class="band-spot" style="top: ${s['pxDownBandLabel']}px; border-top: 1px solid ${s.band_color}; border-left: 5px solid ${s.band_color}; border-bottom: 1px solid ${s.band_color}; border-right: 1px solid ${s.band_color};"><span class="band-spot-call">${s.dx_call}${s.dx_ssid != null ? "-" + s.dx_ssid : ""}</span><span class="band-spot-info">${s.dx_call}${s.dx_ssid != null ? "-" + s.dx_ssid : ""} ${(s.freq/1000000).toFixed(3)} ${s.mode}</span></div>`);
|
bandSpotsDiv.append(`<div class="band-spot" style="top: ${s['pxDownBandLabel']}px; border-top: 1px solid ${bandToColor(s['band'])}; border-left: 5px solid ${bandToColor(s['band'])}; border-bottom: 1px solid ${bandToColor(s['band'])}; border-right: 1px solid ${bandToColor(s['band'])};"><span class="band-spot-call">${s.dx_call}${s.dx_ssid != null ? "-" + s.dx_ssid : ""}</span><span class="band-spot-info">${s.dx_call}${s.dx_ssid != null ? "-" + s.dx_ssid : ""} ${(s.freq/1000000).toFixed(3)} ${s.mode}</span></div>`);
|
||||||
});
|
});
|
||||||
|
|
||||||
// Work out how tall the canvas should be. Normally this is matching the normal band column height, but if some
|
// Work out how tall the canvas should be. Normally this is matching the normal band column height, but if some
|
||||||
@@ -167,7 +167,7 @@ function updateBands() {
|
|||||||
ctx.beginPath();
|
ctx.beginPath();
|
||||||
ctx.lineWidth = 2;
|
ctx.lineWidth = 2;
|
||||||
ctx.lineCap = "round";
|
ctx.lineCap = "round";
|
||||||
ctx.strokeStyle = s.band_color;
|
ctx.strokeStyle = bandToColor(s['band']);
|
||||||
ctx.moveTo(0, pxDownBandFreq);
|
ctx.moveTo(0, pxDownBandFreq);
|
||||||
ctx.lineTo(BAND_COLUMN_CANVAS_WIDTH_PX, pxDownBandLabel);
|
ctx.lineTo(BAND_COLUMN_CANVAS_WIDTH_PX, pxDownBandLabel);
|
||||||
ctx.stroke();
|
ctx.stroke();
|
||||||
@@ -228,6 +228,21 @@ function loadOptions() {
|
|||||||
// Store options
|
// Store options
|
||||||
options = jsonData;
|
options = jsonData;
|
||||||
|
|
||||||
|
// Populate the Display panel
|
||||||
|
options["web-ui-options"]["max-spot-age"].forEach(sc => $("#max-spot-age").append($('<option>', {
|
||||||
|
value: sc * 60,
|
||||||
|
text: sc
|
||||||
|
})));
|
||||||
|
$("#max-spot-age").val(options["web-ui-options"]["max-spot-age-default"] * 60);
|
||||||
|
getAvailableBandColorSchemes().forEach(sc => $("#band-color-scheme").append($('<option>', {
|
||||||
|
value: sc,
|
||||||
|
text: sc
|
||||||
|
})));
|
||||||
|
|
||||||
|
// First pass loading settings, so we can load the band colour scheme before the filters that need to use it
|
||||||
|
loadSettings();
|
||||||
|
setBandColorScheme($("#band-color-scheme option:selected").val());
|
||||||
|
|
||||||
// Add CSS for band toggle buttons
|
// Add CSS for band toggle buttons
|
||||||
addBandToggleColourCSS(options["bands"]);
|
addBandToggleColourCSS(options["bands"]);
|
||||||
|
|
||||||
@@ -237,14 +252,7 @@ function loadOptions() {
|
|||||||
generateMultiToggleFilterCard("#dx-continent-options", "dx_continent", options["continents"]);
|
generateMultiToggleFilterCard("#dx-continent-options", "dx_continent", options["continents"]);
|
||||||
generateMultiToggleFilterCard("#de-continent-options", "de_continent", options["continents"]);
|
generateMultiToggleFilterCard("#de-continent-options", "de_continent", options["continents"]);
|
||||||
generateMultiToggleFilterCard("#mode-options", "mode_type", options["mode_types"]);
|
generateMultiToggleFilterCard("#mode-options", "mode_type", options["mode_types"]);
|
||||||
generateMultiToggleFilterCard("#source-options", "source", options["spot_sources"]);
|
generateSourcesMultiToggleFilterCard(options["spot_sources"], options["web-ui-options"]["spot-providers-enabled-by-default"]);
|
||||||
|
|
||||||
// Populate the Display panel
|
|
||||||
options["web-ui-options"]["max-spot-age"].forEach(sc => $("#max-spot-age").append($('<option>', {
|
|
||||||
value: sc * 60,
|
|
||||||
text: sc
|
|
||||||
})));
|
|
||||||
$("#max-spot-age").val(options["web-ui-options"]["max-spot-age-default"] * 60);
|
|
||||||
|
|
||||||
// Load URL params. These may select things from the various filter & display options, so the function needs
|
// Load URL params. These may select things from the various filter & display options, so the function needs
|
||||||
// to be called after these are set up, but if the URL params ask for "embedded mode", this will suppress
|
// to be called after these are set up, but if the URL params ask for "embedded mode", this will suppress
|
||||||
|
|||||||
@@ -2,9 +2,6 @@
|
|||||||
var options = {};
|
var options = {};
|
||||||
// Last time we updated the spots/alerts list on display.
|
// Last time we updated the spots/alerts list on display.
|
||||||
var lastUpdateTime;
|
var lastUpdateTime;
|
||||||
// Whether "embedded mode" is being used. This removes headers and footers, maximises the remaining content, and
|
|
||||||
// uses URL params to configure the interface options rather than using the user's localstorage.
|
|
||||||
var embeddedMode = false;
|
|
||||||
|
|
||||||
// Load and apply any URL params. This is used for "embedded mode" where another site can embed a version of
|
// 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
|
// Spothole and provide its own interface options rather than using the user's saved ones. These may select things
|
||||||
@@ -18,7 +15,7 @@ function loadURLParams() {
|
|||||||
// top-level html element to use CSS selectors to remove bits of UI.
|
// top-level html element to use CSS selectors to remove bits of UI.
|
||||||
let embedded = params.get("embedded");
|
let embedded = params.get("embedded");
|
||||||
if (embedded != null && embedded === "true") {
|
if (embedded != null && embedded === "true") {
|
||||||
embeddedMode = true;
|
useLocalStorage = false;
|
||||||
$("html").attr("embedded-mode", "true");
|
$("html").attr("embedded-mode", "true");
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -133,27 +130,6 @@ function updateRefreshDisplay() {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Utility function to escape HTML characters from a string.
|
|
||||||
function escapeHtml(str) {
|
|
||||||
if (typeof str !== 'string') {
|
|
||||||
return '';
|
|
||||||
}
|
|
||||||
|
|
||||||
const escapeCharacter = (match) => {
|
|
||||||
switch (match) {
|
|
||||||
case '&': return '&';
|
|
||||||
case '<': return '<';
|
|
||||||
case '>': return '>';
|
|
||||||
case '"': return '"';
|
|
||||||
case '\'': return ''';
|
|
||||||
case '`': return '`';
|
|
||||||
default: return match;
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
return str.replace(/[&<>"'`]/g, escapeCharacter);
|
|
||||||
}
|
|
||||||
|
|
||||||
// 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();
|
||||||
@@ -166,106 +142,6 @@ function columnsUpdated() {
|
|||||||
saveSettings();
|
saveSettings();
|
||||||
}
|
}
|
||||||
|
|
||||||
// Calculate great circle bearing between two lat/lon points.
|
|
||||||
function calcBearing(lat1, lon1, lat2, lon2) {
|
|
||||||
lat1 *= Math.PI / 180;
|
|
||||||
lon1 *= Math.PI / 180;
|
|
||||||
lat2 *= Math.PI / 180;
|
|
||||||
lon2 *= Math.PI / 180;
|
|
||||||
var lonDelta = lon2 - lon1;
|
|
||||||
var y = Math.sin(lonDelta) * Math.cos(lat2);
|
|
||||||
var x = Math.cos(lat1) * Math.sin(lat2) - Math.sin(lat1) * Math.cos(lat2) * Math.cos(lonDelta);
|
|
||||||
var bearing = Math.atan2(y, x);
|
|
||||||
bearing = bearing * (180 / Math.PI);
|
|
||||||
if ( bearing < 0 ) { bearing += 360; }
|
|
||||||
return bearing;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Convert a Maidenhead grid reference of arbitrary precision to the lat/long of the centre point of the square.
|
|
||||||
// Returns null if the grid format is invalid.
|
|
||||||
function latLonForGridCentre(grid) {
|
|
||||||
let [lat, lon, latCellSize, lonCellSize] = latLonForGridSWCornerPlusSize(grid);
|
|
||||||
if (lat != null && lon != null && latCellSize != null && lonCellSize != null) {
|
|
||||||
return [lat + latCellSize / 2.0, lon + lonCellSize / 2.0];
|
|
||||||
} else {
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Convert a Maidenhead grid reference of arbitrary precision to lat/long, including in the result the size of the
|
|
||||||
// lowest grid square. This is a utility method used by the main methods that return the centre, southwest, and
|
|
||||||
// northeast coordinates of a grid square.
|
|
||||||
// The return type is always an array of size 4. The elements in it are null if the grid format is invalid.
|
|
||||||
function latLonForGridSWCornerPlusSize(grid) {
|
|
||||||
// Make sure we are in upper case so our maths works. Case is arbitrary for Maidenhead references
|
|
||||||
grid = grid.toUpperCase();
|
|
||||||
|
|
||||||
// Return null if our Maidenhead string is invalid or too short
|
|
||||||
let len = grid.length;
|
|
||||||
if (len <= 0 || (len % 2) !== 0) {
|
|
||||||
return [null, null, null, null];
|
|
||||||
}
|
|
||||||
|
|
||||||
let lat = 0.0; // aggregated latitude
|
|
||||||
let lon = 0.0; // aggregated longitude
|
|
||||||
let latCellSize = 10; // Size in degrees latitude of the current cell. Starts at 20 and gets smaller as the calculation progresses
|
|
||||||
let lonCellSize = 20; // Size in degrees longitude of the current cell. Starts at 20 and gets smaller as the calculation progresses
|
|
||||||
let latCellNo; // grid latitude cell number this time
|
|
||||||
let lonCellNo; // grid longitude cell number this time
|
|
||||||
|
|
||||||
// Iterate through blocks (two-character sections)
|
|
||||||
for (let block = 0; block * 2 < len; block += 1) {
|
|
||||||
if (block % 2 === 0) {
|
|
||||||
// Letters in this block
|
|
||||||
lonCellNo = grid.charCodeAt(block * 2) - 'A'.charCodeAt(0);
|
|
||||||
latCellNo = grid.charCodeAt(block * 2 + 1) - 'A'.charCodeAt(0);
|
|
||||||
// Bail if the values aren't in range. Allowed values are A-R (0-17) for the first letter block, or
|
|
||||||
// A-X (0-23) thereafter.
|
|
||||||
let maxCellNo = (block === 0) ? 17 : 23;
|
|
||||||
if (latCellNo < 0 || latCellNo > maxCellNo || lonCellNo < 0 || lonCellNo > maxCellNo) {
|
|
||||||
return [null, null, null, null];
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
// Numbers in this block
|
|
||||||
lonCellNo = parseInt(grid.charAt(block * 2));
|
|
||||||
latCellNo = parseInt(grid.charAt(block * 2 + 1));
|
|
||||||
// Bail if the values aren't in range 0-9..
|
|
||||||
if (latCellNo < 0 || latCellNo > 9 || lonCellNo < 0 || lonCellNo > 9) {
|
|
||||||
return [null, null, null, null];
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Aggregate the angles
|
|
||||||
lat += latCellNo * latCellSize;
|
|
||||||
lon += lonCellNo * lonCellSize;
|
|
||||||
|
|
||||||
// Reduce the cell size for the next block, unless we are on the last cell.
|
|
||||||
if (block * 2 < len - 2) {
|
|
||||||
// Still have more work to do, so reduce the cell size
|
|
||||||
if (block % 2 === 0) {
|
|
||||||
// Just dealt with letters, next block will be numbers so cells will be 1/10 the current size
|
|
||||||
latCellSize = latCellSize / 10.0;
|
|
||||||
lonCellSize = lonCellSize / 10.0;
|
|
||||||
} else {
|
|
||||||
// Just dealt with numbers, next block will be letters so cells will be 1/24 the current size
|
|
||||||
latCellSize = latCellSize / 24.0;
|
|
||||||
lonCellSize = lonCellSize / 24.0;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Offset back to (-180, -90) where the grid starts
|
|
||||||
lon -= 180.0;
|
|
||||||
lat -= 90.0;
|
|
||||||
|
|
||||||
// Return nulls on maths errors
|
|
||||||
if (isNaN(lat) || isNaN(lon) || isNaN(latCellSize) || isNaN(lonCellSize)) {
|
|
||||||
return [null, null, null, null];
|
|
||||||
}
|
|
||||||
|
|
||||||
return [lat, lon, latCellSize, lonCellSize];
|
|
||||||
}
|
|
||||||
|
|
||||||
// Function to set dark mode on or off
|
// Function to set dark mode on or off
|
||||||
function enableDarkMode(dark) {
|
function enableDarkMode(dark) {
|
||||||
$("html").attr("data-bs-theme", dark ? "dark" : "light");
|
$("html").attr("data-bs-theme", dark ? "dark" : "light");
|
||||||
@@ -289,37 +165,6 @@ function usePreferredTheme() {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Save settings to local storage. Suppressed if "embedded mode" is in use.
|
|
||||||
function saveSettings() {
|
|
||||||
if (!embeddedMode) {
|
|
||||||
// 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));
|
|
||||||
});
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Load settings from local storage and set up the filter selectors. Suppressed if "embedded mode" is in use.
|
|
||||||
function loadSettings() {
|
|
||||||
if (!embeddedMode) {
|
|
||||||
// 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)));
|
|
||||||
}
|
|
||||||
});
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Startup
|
// Startup
|
||||||
$(document).ready(function() {
|
$(document).ready(function() {
|
||||||
usePreferredTheme();
|
usePreferredTheme();
|
||||||
|
|||||||
@@ -45,12 +45,16 @@ function updateMap() {
|
|||||||
|
|
||||||
// Create geodesics if required
|
// Create geodesics if required
|
||||||
if ($("#mapShowGeodesics")[0].checked && s["de_latitude"] != null && s["de_longitude"] != null) {
|
if ($("#mapShowGeodesics")[0].checked && s["de_latitude"] != null && s["de_longitude"] != null) {
|
||||||
var geodesic = L.geodesic([[s["de_latitude"], s["de_longitude"]], m.getLatLng()], {
|
try {
|
||||||
color: s["band_color"],
|
var geodesic = L.geodesic([[s["de_latitude"], s["de_longitude"]], m.getLatLng()], {
|
||||||
wrap: false,
|
color: bandToColor(s['band']),
|
||||||
steps: 5
|
wrap: false,
|
||||||
});
|
steps: 5
|
||||||
geodesicsLayer.addLayer(geodesic);
|
});
|
||||||
|
geodesicsLayer.addLayer(geodesic);
|
||||||
|
} catch (e) {
|
||||||
|
// Not sure what causes these but better to continue than to crash out
|
||||||
|
}
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
@@ -58,9 +62,9 @@ function updateMap() {
|
|||||||
// Get an icon for a spot, based on its band, using PSK Reporter colours, its program etc.
|
// Get an icon for a spot, based on its band, using PSK Reporter colours, its program etc.
|
||||||
function getIcon(s) {
|
function getIcon(s) {
|
||||||
return L.ExtraMarkers.icon({
|
return L.ExtraMarkers.icon({
|
||||||
icon: "fa-" + s["icon"],
|
icon: sigToIcon(s["sig"], "fa-tower-cell"),
|
||||||
iconColor: s["band_contrast_color"],
|
iconColor: bandToContrastColor(s["band"]),
|
||||||
markerColor: s["band_color"],
|
markerColor: bandToColor(s["band"]),
|
||||||
shape: 'circle',
|
shape: 'circle',
|
||||||
prefix: 'fa',
|
prefix: 'fa',
|
||||||
svg: true
|
svg: true
|
||||||
@@ -136,7 +140,7 @@ function getTooltipText(s) {
|
|||||||
ttt += "<br/>";
|
ttt += "<br/>";
|
||||||
|
|
||||||
// Source / SIG / Ref
|
// Source / SIG / Ref
|
||||||
ttt += `<span class='nowrap'><span class='icon-wrapper'><i class='fa-solid fa-${s["icon"]}'></i></span> ${sigSourceText} ${sig_refs}</span><br/>`;
|
ttt += `<span class='nowrap'><span class='icon-wrapper'><i class='fa-solid ${sigToIcon(s["sig"], "fa-tower-cell")}'></i></span> ${sigSourceText} ${sig_refs}</span><br/>`;
|
||||||
|
|
||||||
// Time
|
// Time
|
||||||
ttt += `<span class='icon-wrapper'><i class='fa-solid fa-clock markerPopupIcon'></i></span> ${moment.unix(s["time"]).fromNow()}`;
|
ttt += `<span class='icon-wrapper'><i class='fa-solid fa-clock markerPopupIcon'></i></span> ${moment.unix(s["time"]).fromNow()}`;
|
||||||
@@ -156,6 +160,21 @@ function loadOptions() {
|
|||||||
// Store options
|
// Store options
|
||||||
options = jsonData;
|
options = jsonData;
|
||||||
|
|
||||||
|
// Populate the Display panel
|
||||||
|
options["web-ui-options"]["max-spot-age"].forEach(sc => $("#max-spot-age").append($('<option>', {
|
||||||
|
value: sc * 60,
|
||||||
|
text: sc
|
||||||
|
})));
|
||||||
|
$("#max-spot-age").val(options["web-ui-options"]["max-spot-age-default"] * 60);
|
||||||
|
getAvailableBandColorSchemes().forEach(sc => $("#band-color-scheme").append($('<option>', {
|
||||||
|
value: sc,
|
||||||
|
text: sc
|
||||||
|
})));
|
||||||
|
|
||||||
|
// First pass loading settings, so we can load the band colour scheme before the filters that need to use it
|
||||||
|
loadSettings();
|
||||||
|
setBandColorScheme($("#band-color-scheme option:selected").val());
|
||||||
|
|
||||||
// Add CSS for band toggle buttons
|
// Add CSS for band toggle buttons
|
||||||
addBandToggleColourCSS(options["bands"]);
|
addBandToggleColourCSS(options["bands"]);
|
||||||
|
|
||||||
@@ -165,14 +184,7 @@ function loadOptions() {
|
|||||||
generateMultiToggleFilterCard("#dx-continent-options", "dx_continent", options["continents"]);
|
generateMultiToggleFilterCard("#dx-continent-options", "dx_continent", options["continents"]);
|
||||||
generateMultiToggleFilterCard("#de-continent-options", "de_continent", options["continents"]);
|
generateMultiToggleFilterCard("#de-continent-options", "de_continent", options["continents"]);
|
||||||
generateMultiToggleFilterCard("#mode-options", "mode_type", options["mode_types"]);
|
generateMultiToggleFilterCard("#mode-options", "mode_type", options["mode_types"]);
|
||||||
generateMultiToggleFilterCard("#source-options", "source", options["spot_sources"]);
|
generateSourcesMultiToggleFilterCard(options["spot_sources"], options["web-ui-options"]["spot-providers-enabled-by-default"]);
|
||||||
|
|
||||||
// Populate the Display panel
|
|
||||||
options["web-ui-options"]["max-spot-age"].forEach(sc => $("#max-spot-age").append($('<option>', {
|
|
||||||
value: sc * 60,
|
|
||||||
text: sc
|
|
||||||
})));
|
|
||||||
$("#max-spot-age").val(options["web-ui-options"]["max-spot-age-default"] * 60);
|
|
||||||
|
|
||||||
// Load URL params. These may select things from the various filter & display options, so the function needs
|
// Load URL params. These may select things from the various filter & display options, so the function needs
|
||||||
// to be called after these are set up, but if the URL params ask for "embedded mode", this will suppress
|
// to be called after these are set up, but if the URL params ask for "embedded mode", this will suppress
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
// SSE event source
|
// SSE event source
|
||||||
let evtSource;
|
let evtSource;
|
||||||
|
let restartSSEOnErrorTimeoutId;
|
||||||
// Table row count, to alternate shading
|
// Table row count, to alternate shading
|
||||||
let rowCount = 0;
|
let rowCount = 0;
|
||||||
|
|
||||||
@@ -30,6 +31,9 @@ function loadSpots() {
|
|||||||
// Start an SSE connection (closing an existing one if it exists). This will then be used to add to the table on the
|
// Start an SSE connection (closing an existing one if it exists). This will then be used to add to the table on the
|
||||||
// fly.
|
// fly.
|
||||||
function startSSEConnection() {
|
function startSSEConnection() {
|
||||||
|
if (evtSource != null) {
|
||||||
|
evtSource.close();
|
||||||
|
}
|
||||||
evtSource = new EventSource('/api/v1/spots/stream' + buildQueryString());
|
evtSource = new EventSource('/api/v1/spots/stream' + buildQueryString());
|
||||||
|
|
||||||
evtSource.onmessage = function(event) {
|
evtSource.onmessage = function(event) {
|
||||||
@@ -66,8 +70,11 @@ function startSSEConnection() {
|
|||||||
};
|
};
|
||||||
|
|
||||||
evtSource.onerror = function(err) {
|
evtSource.onerror = function(err) {
|
||||||
evtSource.close();
|
if (evtSource != null) {
|
||||||
setTimeout(startSSEConnection, 1000);
|
evtSource.close();
|
||||||
|
}
|
||||||
|
clearTimeout(restartSSEOnErrorTimeoutId)
|
||||||
|
restartSSEOnErrorTimeoutId = setTimeout(startSSEConnection, 1000);
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -147,8 +154,8 @@ function updateTable() {
|
|||||||
table.find('tbody').append('<tr class="table-danger"><td colspan="100" style="text-align:center;">No spots match your filters.</td></tr>');
|
table.find('tbody').append('<tr class="table-danger"><td colspan="100" style="text-align:center;">No spots match your filters.</td></tr>');
|
||||||
}
|
}
|
||||||
|
|
||||||
spots.reverse();
|
let spotsNewestFirst = spots.toReversed();
|
||||||
spots.forEach(s => addSpotToTopOfTable(s, false));
|
spotsNewestFirst.forEach(s => addSpotToTopOfTable(s, false));
|
||||||
}
|
}
|
||||||
|
|
||||||
// Add rows corresponding to a new spot to the top of the table
|
// Add rows corresponding to a new spot to the top of the table
|
||||||
@@ -182,9 +189,6 @@ function createNewTableRowsForSpot(s, highlightNew) {
|
|||||||
|
|
||||||
// Create row
|
// Create row
|
||||||
let $tr = $('<tr>');
|
let $tr = $('<tr>');
|
||||||
if (highlightNew) {
|
|
||||||
$tr.addClass("new");
|
|
||||||
}
|
|
||||||
|
|
||||||
// Apply striping to the table. We can't just use Bootstrap's table-striped class because we have all sorts of
|
// Apply striping to the table. We can't just use Bootstrap's table-striped class because we have all sorts of
|
||||||
// extra faff to deal with, like the mobile view having extra rows, and the On Now / Next 24h / Later banners
|
// extra faff to deal with, like the mobile view having extra rows, and the On Now / Next 24h / Later banners
|
||||||
@@ -195,7 +199,13 @@ function createNewTableRowsForSpot(s, highlightNew) {
|
|||||||
|
|
||||||
// Show faded out if QRT
|
// Show faded out if QRT
|
||||||
if (s["qrt"] == true) {
|
if (s["qrt"] == true) {
|
||||||
$tr.addClass("table-faded");
|
$tr.addClass("table-faded");
|
||||||
|
}
|
||||||
|
|
||||||
|
// If we are asked to highlight new rows (i.e. this row is being added "live" via the SSE client and not as a bulk
|
||||||
|
// reload of the whole table)
|
||||||
|
if (highlightNew) {
|
||||||
|
$tr.addClass("new");
|
||||||
}
|
}
|
||||||
|
|
||||||
// Format a UTC or local time for display
|
// Format a UTC or local time for display
|
||||||
@@ -277,9 +287,9 @@ function createNewTableRowsForSpot(s, highlightNew) {
|
|||||||
var items = []
|
var items = []
|
||||||
for (var i = 0; i < s["sig_refs"].length; i++) {
|
for (var i = 0; i < s["sig_refs"].length; i++) {
|
||||||
if (s["sig_refs"][i]["url"] != null) {
|
if (s["sig_refs"][i]["url"] != null) {
|
||||||
items[i] = `<a href='${s["sig_refs"][i]["url"]}' title='${s["sig_refs"][i]["name"]}' target='_new' class='sig-ref-link'>${s["sig_refs"][i]["id"]}</a>`
|
items[i] = `<span style="white-space: nowrap;"><a href='${s["sig_refs"][i]["url"]}' title='${s["sig_refs"][i]["name"]}' target='_new' class='sig-ref-link'>${s["sig_refs"][i]["id"]}</a></span>`
|
||||||
} else {
|
} else {
|
||||||
items[i] = `${s["sig_refs"][i]["id"]}`
|
items[i] = `<span style="white-space: nowrap;">${s["sig_refs"][i]["id"]}</span>`
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
sig_refs = items.join(", ");
|
sig_refs = items.join(", ");
|
||||||
@@ -315,10 +325,10 @@ function createNewTableRowsForSpot(s, highlightNew) {
|
|||||||
$tr.append(`<td class='nowrap'>${time_formatted}</td>`);
|
$tr.append(`<td class='nowrap'>${time_formatted}</td>`);
|
||||||
}
|
}
|
||||||
if (showDX) {
|
if (showDX) {
|
||||||
$tr.append(`<td class='nowrap'><span class='flag-wrapper hideonmobile' title='${dx_country}'>${dx_flag}</span><a class='dx-link' href='https://qrz.com/db/${s["dx_call"]}' target='_new' title='${s["dx_name"] != null ? s["dx_name"] : ""}'>${dx_call}</a></td>`);
|
$tr.append(`<td class='nowrap'><span class='flag-wrapper' title='${dx_country}'>${dx_flag}</span><a class='dx-link' href='https://qrz.com/db/${s["dx_call"]}' target='_new' title='${s["dx_name"] != null ? s["dx_name"] : ""}'>${dx_call}</a></td>`);
|
||||||
}
|
}
|
||||||
if (showFreq) {
|
if (showFreq) {
|
||||||
$tr.append(`<td class='nowrap'><span class='band-bullet' title='${bandFullName}' style='${(s["freq"] != null) ? "color: " + s["band_color"] : "display: none;"}'>■</span>${freq_string}</td>`);
|
$tr.append(`<td class='nowrap'><span class='band-bullet' title='${bandFullName}' style='${(s["freq"] != null) ? "color: " + bandToColor(s["band"]) : "display: none;"}'>■</span>${freq_string}</td>`);
|
||||||
}
|
}
|
||||||
if (showMode) {
|
if (showMode) {
|
||||||
$tr.append(`<td class='nowrap'>${mode_string}</td>`);
|
$tr.append(`<td class='nowrap'>${mode_string}</td>`);
|
||||||
@@ -330,10 +340,10 @@ function createNewTableRowsForSpot(s, highlightNew) {
|
|||||||
$tr.append(`<td class='nowrap hideonmobile'>${bearingText}</td>`);
|
$tr.append(`<td class='nowrap hideonmobile'>${bearingText}</td>`);
|
||||||
}
|
}
|
||||||
if (showType) {
|
if (showType) {
|
||||||
$tr.append(`<td class='nowrap hideonmobile'><span class='icon-wrapper'><i class='fa-solid fa-${s["icon"]}'></i></span> ${typeText}</td>`);
|
$tr.append(`<td class='nowrap hideonmobile'><span class='icon-wrapper'><i class='fa-solid ${sigToIcon(s["sig"], "fa-tower-cell")}'></i></span> ${typeText}</td>`);
|
||||||
}
|
}
|
||||||
if (showRef) {
|
if (showRef) {
|
||||||
$tr.append(`<td class='hideonmobile'>${sig_refs}</td>`);
|
$tr.append(`<td class='hideonmobile' style='max-width: 11em;'>${sig_refs}</td>`);
|
||||||
}
|
}
|
||||||
if (showDE) {
|
if (showDE) {
|
||||||
$tr.append(`<td class='nowrap hideonmobile'><span class='flag-wrapper' title='${de_country}'>${de_flag}</span>${de_call}</td>`);
|
$tr.append(`<td class='nowrap hideonmobile'><span class='flag-wrapper' title='${de_country}'>${de_flag}</span>${de_call}</td>`);
|
||||||
@@ -341,24 +351,38 @@ function createNewTableRowsForSpot(s, highlightNew) {
|
|||||||
|
|
||||||
// Second row for mobile view only, containing type, ref & comment
|
// Second row for mobile view only, containing type, ref & comment
|
||||||
$tr2 = $("<tr class='hidenotonmobile'>");
|
$tr2 = $("<tr class='hidenotonmobile'>");
|
||||||
|
|
||||||
|
// Apply styles as per the first row
|
||||||
if (rowCount % 2 == 1) {
|
if (rowCount % 2 == 1) {
|
||||||
$tr2.addClass("table-active");
|
$tr2.addClass("table-active");
|
||||||
}
|
}
|
||||||
if (s["qrt"] == true) {
|
if (s["qrt"] == true) {
|
||||||
$tr2.addClass("table-faded");
|
$tr2.addClass("table-faded");
|
||||||
}
|
}
|
||||||
|
if (highlightNew) {
|
||||||
|
$tr2.addClass("new");
|
||||||
|
}
|
||||||
|
|
||||||
$td2 = $("<td colspan='100'>");
|
$td2 = $("<td colspan='100'>");
|
||||||
|
$td2floatleft = $(`<div style="float: left;">`);
|
||||||
if (showType) {
|
if (showType) {
|
||||||
$td2.append(`<span class='icon-wrapper'><i class='fa-solid fa-${s["icon"]}'></i></span> ${typeText} `);
|
$td2floatleft.append(`<span class='icon-wrapper'><i class='fa-solid ${sigToIcon(s["sig"], "fa-tower-cell")}'></i></span> ${typeText} `);
|
||||||
}
|
}
|
||||||
if (showRef) {
|
if (showRef) {
|
||||||
$td2.append(`${sig_refs} `);
|
$td2floatleft.append(`${sig_refs} `);
|
||||||
}
|
}
|
||||||
|
$td2.append($td2floatleft);
|
||||||
|
$td2floatright = $(`<div style="float: right;">`);
|
||||||
if (showBearing) {
|
if (showBearing) {
|
||||||
$td2.append(` Bearing: ${bearingText} `);
|
$td2floatright.append(`${bearingText} `);
|
||||||
}
|
}
|
||||||
|
if (showDE) {
|
||||||
|
$td2floatright.append(` de ${de_call} `);
|
||||||
|
}
|
||||||
|
$td2.append($td2floatright);
|
||||||
|
$td2.append(`</div><div style="clear: both;"></div>`);
|
||||||
if (showComment) {
|
if (showComment) {
|
||||||
$td2.append(`<br/>${commentText}`);
|
$td2.append(`${commentText}`);
|
||||||
}
|
}
|
||||||
$tr2.append($td2);
|
$tr2.append($td2);
|
||||||
|
|
||||||
@@ -374,6 +398,21 @@ function loadOptions() {
|
|||||||
// Store options
|
// Store options
|
||||||
options = jsonData;
|
options = jsonData;
|
||||||
|
|
||||||
|
// Populate the Display panel
|
||||||
|
options["web-ui-options"]["spot-count"].forEach(sc => $("#spots-to-fetch").append($('<option>', {
|
||||||
|
value: sc,
|
||||||
|
text: sc
|
||||||
|
})));
|
||||||
|
$("#spots-to-fetch").val(options["web-ui-options"]["spot-count-default"]);
|
||||||
|
getAvailableBandColorSchemes().forEach(sc => $("#band-color-scheme").append($('<option>', {
|
||||||
|
value: sc,
|
||||||
|
text: sc
|
||||||
|
})));
|
||||||
|
|
||||||
|
// First pass loading settings, so we can load the band colour scheme before the filters that need to use it
|
||||||
|
loadSettings();
|
||||||
|
setBandColorScheme($("#band-color-scheme option:selected").val());
|
||||||
|
|
||||||
// Add CSS for band toggle buttons
|
// Add CSS for band toggle buttons
|
||||||
addBandToggleColourCSS(options["bands"]);
|
addBandToggleColourCSS(options["bands"]);
|
||||||
|
|
||||||
@@ -383,14 +422,7 @@ function loadOptions() {
|
|||||||
generateMultiToggleFilterCard("#dx-continent-options", "dx_continent", options["continents"]);
|
generateMultiToggleFilterCard("#dx-continent-options", "dx_continent", options["continents"]);
|
||||||
generateMultiToggleFilterCard("#de-continent-options", "de_continent", options["continents"]);
|
generateMultiToggleFilterCard("#de-continent-options", "de_continent", options["continents"]);
|
||||||
generateMultiToggleFilterCard("#mode-options", "mode_type", options["mode_types"]);
|
generateMultiToggleFilterCard("#mode-options", "mode_type", options["mode_types"]);
|
||||||
generateMultiToggleFilterCard("#source-options", "source", options["spot_sources"]);
|
generateSourcesMultiToggleFilterCard(options["spot_sources"], options["web-ui-options"]["spot-providers-enabled-by-default"]);
|
||||||
|
|
||||||
// Populate the Display panel
|
|
||||||
options["web-ui-options"]["spot-count"].forEach(sc => $("#spots-to-fetch").append($('<option>', {
|
|
||||||
value: sc,
|
|
||||||
text: sc
|
|
||||||
})));
|
|
||||||
$("#spots-to-fetch").val(options["web-ui-options"]["spot-count-default"]);
|
|
||||||
|
|
||||||
// Load URL params. These may select things from the various filter & display options, so the function needs
|
// Load URL params. These may select things from the various filter & display options, so the function needs
|
||||||
// to be called after these are set up, but if the URL params ask for "embedded mode", this will suppress
|
// to be called after these are set up, but if the URL params ask for "embedded mode", this will suppress
|
||||||
|
|||||||
@@ -6,10 +6,9 @@ var spots = []
|
|||||||
function addBandToggleColourCSS(band_options) {
|
function addBandToggleColourCSS(band_options) {
|
||||||
var $style = $('<style>');
|
var $style = $('<style>');
|
||||||
band_options.forEach(o => {
|
band_options.forEach(o => {
|
||||||
// CSS doesn't like IDs with decimal points in, so we need to replace that
|
var domSafeName = o["name"].replace(/^[^A-Za-z0-9]+|[^\w]+/gi, "");
|
||||||
var cssFormattedBandName = o['name'] ? o['name'].replace('.', 'p') : "unknown";
|
$style.append(`#filter-button-label-band-${domSafeName} { border-color: ${bandToColor(o['name'])}; color: var(--bs-primary);}`);
|
||||||
$style.append(`#filter-button-label-band-${cssFormattedBandName} { border-color: ${o['color']}; color: var(--bs-primary);}`);
|
$style.append(`.btn-check:checked + #filter-button-label-band-${domSafeName} { background-color: ${bandToColor(o['name'])}; color: ${bandToContrastColor(o['name'])};}`);
|
||||||
$style.append(`.btn-check:checked + #filter-button-label-band-${cssFormattedBandName} { background-color: ${o['color']}; color: ${o['contrast_color']};}`);
|
|
||||||
});
|
});
|
||||||
$('html > head').append($style);
|
$('html > head').append($style);
|
||||||
}
|
}
|
||||||
@@ -18,20 +17,29 @@ function addBandToggleColourCSS(band_options) {
|
|||||||
function generateBandsMultiToggleFilterCard(band_options) {
|
function generateBandsMultiToggleFilterCard(band_options) {
|
||||||
// Create a button for each option
|
// Create a button for each option
|
||||||
band_options.forEach(o => {
|
band_options.forEach(o => {
|
||||||
// CSS doesn't like IDs with decimal points in, so we need to replace that in the same way as when we originally
|
var domSafeName = o["name"].replace(/^[^A-Za-z0-9]+|[^\w]+/gi, "");
|
||||||
// queried the options endpoint and set our CSS.
|
$("#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" id="filter-button-label-band-${domSafeName}" for="filter-button-band-${domSafeName}">${o['name']}</label> `);
|
||||||
var cssFormattedBandName = o['name'] ? o['name'].replace('.', 'p') : "unknown";
|
|
||||||
$("#band-options").append(`<input type="checkbox" class="btn-check filter-button-band storeable-checkbox" name="options" id="filter-button-band-${cssFormattedBandName}" value="${o['name']}" autocomplete="off" onClick="filtersUpdated()" checked><label class="btn btn-outline" id="filter-button-label-band-${cssFormattedBandName}" for="filter-button-band-${cssFormattedBandName}">${o['name']}</label> `);
|
|
||||||
});
|
});
|
||||||
// Create All/None buttons
|
// 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></span>`);
|
$("#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>`);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Set the band toggles so that only the amateur radio HF bands are selected. This includes 160m and 6m because that's
|
||||||
|
// widely expected by hams to be included. Special case of toggleFilterButtons().
|
||||||
|
function setHamHFBandToggles() {
|
||||||
|
const hamHFBands = ["160m", "80m", "60m", "40m", "30m", "20m", "17m", "15m", "12m", "10m", "6m"];
|
||||||
|
$(".filter-button-band").each(function() {
|
||||||
|
$(this).prop('checked', hamHFBands.includes($(this).val().replace("filter-button-band-", "")));
|
||||||
|
});
|
||||||
|
filtersUpdated();
|
||||||
}
|
}
|
||||||
|
|
||||||
// Generate SIGs filter card. This one is also a special case.
|
// Generate SIGs filter card. This one is also a special case.
|
||||||
function generateSIGsMultiToggleFilterCard(sig_options) {
|
function generateSIGsMultiToggleFilterCard(sig_options) {
|
||||||
// Create a button for each option
|
// Create a button for each option
|
||||||
sig_options.forEach(o => {
|
sig_options.forEach(o => {
|
||||||
$("#sig-options").append(`<input type="checkbox" class="btn-check filter-button-sig storeable-checkbox" name="options" id="filter-button-sig-${o['name']}" value="${o['name']}" autocomplete="off" onClick="filtersUpdated()" checked><label class="btn btn-outline-primary" id="filter-button-label-sig-${o['name']}" for="filter-button-sig-${o['name']}" title="${o['description']}"><i class="fa-solid fa-${o['icon']}"></i> ${o['name']}</label> `);
|
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-primary" 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> `);
|
||||||
});
|
});
|
||||||
// Create a bonus "NO_SIG" / "General DX" option
|
// 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-primary" id="filter-button-label-sig-NO_SIG" for="filter-button-sig-NO_SIG"><i class="fa-solid fa-tower-cell"></i> General DX</label> `);
|
$("#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-primary" id="filter-button-label-sig-NO_SIG" for="filter-button-sig-NO_SIG"><i class="fa-solid fa-tower-cell"></i> General DX</label> `);
|
||||||
@@ -39,6 +47,20 @@ function generateSIGsMultiToggleFilterCard(sig_options) {
|
|||||||
$("#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> <button id="filter-button-sig-none" type="button" class="btn btn-outline-secondary" onclick="toggleFilterButtons('sig', false);">None</button></span>`);
|
$("#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> <button id="filter-button-sig-none" type="button" class="btn btn-outline-secondary" onclick="toggleFilterButtons('sig', false);">None</button></span>`);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Generate Sources filter card. This one is a minor special case as we create the buttons in the normal way, but then
|
||||||
|
// 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
|
||||||
|
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-primary" for="filter-button-source-${domSafeName}">${o}</label> `);
|
||||||
|
});
|
||||||
|
// 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> <button id="filter-button-source-none" type="button" class="btn btn-outline-secondary" onclick="toggleFilterButtons('source', false);">None</button></span>`);
|
||||||
|
}
|
||||||
|
|
||||||
// Method called when any filter is changed to reload the spots and persist the filter settings.
|
// Method called when any filter is changed to reload the spots and persist the filter settings.
|
||||||
function filtersUpdated() {
|
function filtersUpdated() {
|
||||||
loadSpots();
|
loadSpots();
|
||||||
@@ -49,4 +71,20 @@ function filtersUpdated() {
|
|||||||
function toggleDarkMode() {
|
function toggleDarkMode() {
|
||||||
enableDarkMode($("#darkMode")[0].checked);
|
enableDarkMode($("#darkMode")[0].checked);
|
||||||
saveSettings();
|
saveSettings();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Function to update the band colour scheme in spots, bands and map pages
|
||||||
|
function setBandColorSchemeFromUI() {
|
||||||
|
setBandColorScheme($("#band-color-scheme option:selected").val());
|
||||||
|
saveSettings();
|
||||||
|
// Fudge a full reload because we need to update not just colours in the list/map/bands but also the filters
|
||||||
|
window.location.reload();
|
||||||
|
}
|
||||||
|
|
||||||
|
// Reload spots on becoming visible. This forces a refresh when used as a PWA and the user switches back to the PWA
|
||||||
|
// after some time has passed with it in the background.
|
||||||
|
addEventListener("visibilitychange", (event) => {
|
||||||
|
if (!document.hidden) {
|
||||||
|
loadSpots();
|
||||||
|
}
|
||||||
|
});
|
||||||
Reference in New Issue
Block a user