mirror of
https://git.ianrenton.com/ian/spothole.git
synced 2025-10-27 16:59:25 +00:00
Compare commits
1 Commits
8b036ddb46
...
44-contain
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
885b832661 |
@@ -14,8 +14,6 @@ Supported data sources include DX Clusters, the Reverse Beacon Network (RBN), th
|
|||||||
|
|
||||||

|

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

|
|
||||||
|
|
||||||
### Accessing the public version
|
### Accessing the public version
|
||||||
|
|
||||||
You can access the public version's web interface at [https://spothole.app](https://spothole.app), and see [https://spothole.app/apidocs](https://spothole.app/apidocs) for the API details.
|
You can access the public version's web interface at [https://spothole.app](https://spothole.app), and see [https://spothole.app/apidocs](https://spothole.app/apidocs) for the API details.
|
||||||
|
|||||||
@@ -1,56 +0,0 @@
|
|||||||
import logging
|
|
||||||
from datetime import datetime
|
|
||||||
|
|
||||||
import pytz
|
|
||||||
|
|
||||||
from alertproviders.http_alert_provider import HTTPAlertProvider
|
|
||||||
from core.sig_utils import get_icon_for_sig
|
|
||||||
from data.alert import Alert
|
|
||||||
|
|
||||||
|
|
||||||
# Alert provider for Parks n Peaks
|
|
||||||
class ParksNPeaks(HTTPAlertProvider):
|
|
||||||
POLL_INTERVAL_SEC = 3600
|
|
||||||
ALERTS_URL = "http://parksnpeaks.org/api/ALERTS/"
|
|
||||||
|
|
||||||
def __init__(self, provider_config):
|
|
||||||
super().__init__(provider_config, self.ALERTS_URL, self.POLL_INTERVAL_SEC)
|
|
||||||
|
|
||||||
def http_response_to_alerts(self, http_response):
|
|
||||||
new_alerts = []
|
|
||||||
# Iterate through source data
|
|
||||||
for source_alert in http_response.json():
|
|
||||||
# Calculate some things
|
|
||||||
if " - " in source_alert["Location"]:
|
|
||||||
split = source_alert["Location"].split(" - ")
|
|
||||||
sig_ref = split[0]
|
|
||||||
sig_ref_name = split[1]
|
|
||||||
else:
|
|
||||||
sig_ref = source_alert["WWFFID"]
|
|
||||||
sig_ref_name = source_alert["Location"]
|
|
||||||
start_time = datetime.strptime(source_alert["alTime"], "%Y-%m-%d %H:%M:%S").replace(
|
|
||||||
tzinfo=pytz.UTC).timestamp()
|
|
||||||
|
|
||||||
# Convert to our alert format
|
|
||||||
alert = Alert(source=self.name,
|
|
||||||
source_id=source_alert["alID"],
|
|
||||||
dx_calls=[source_alert["CallSign"].upper()],
|
|
||||||
freqs_modes=source_alert["Freq"] + " " + source_alert["MODE"],
|
|
||||||
comment=source_alert["Comments"],
|
|
||||||
sig=source_alert["Class"],
|
|
||||||
sig_refs=[sig_ref],
|
|
||||||
sig_refs_names=[sig_ref_name],
|
|
||||||
icon=get_icon_for_sig(source_alert["Class"]),
|
|
||||||
start_time=start_time,
|
|
||||||
is_dxpedition=False)
|
|
||||||
|
|
||||||
# Log a warning for the developer if PnP gives us an unknown programme we've never seen before
|
|
||||||
if alert.sig not in ["POTA", "SOTA", "WWFF", "SiOTA", "ZLOTA", "KRMNPA"]:
|
|
||||||
logging.warn("PNP alert found with sig " + alert.sig + ", developer needs to add support for this!")
|
|
||||||
|
|
||||||
# If this is POTA, SOTA or WWFF data we already have it through other means, so ignore. Otherwise, add to
|
|
||||||
# the alert list. Note that while ZLOTA has its own spots API, it doesn't have its own alerts API. So that
|
|
||||||
# means the PnP *spot* provider rejects ZLOTA spots here, but the PnP *alerts* provider here allows ZLOTA.
|
|
||||||
if alert.sig not in ["POTA", "SOTA", "WWFF"]:
|
|
||||||
new_alerts.append(alert)
|
|
||||||
return new_alerts
|
|
||||||
@@ -3,7 +3,6 @@ from datetime import datetime
|
|||||||
import pytz
|
import pytz
|
||||||
|
|
||||||
from alertproviders.http_alert_provider import HTTPAlertProvider
|
from alertproviders.http_alert_provider import HTTPAlertProvider
|
||||||
from core.sig_utils import get_icon_for_sig
|
|
||||||
from data.alert import Alert
|
from data.alert import Alert
|
||||||
|
|
||||||
|
|
||||||
@@ -28,7 +27,7 @@ class POTA(HTTPAlertProvider):
|
|||||||
sig="POTA",
|
sig="POTA",
|
||||||
sig_refs=[source_alert["reference"]],
|
sig_refs=[source_alert["reference"]],
|
||||||
sig_refs_names=[source_alert["name"]],
|
sig_refs_names=[source_alert["name"]],
|
||||||
icon=get_icon_for_sig("POTA"),
|
icon="tree",
|
||||||
start_time=datetime.strptime(source_alert["startDate"] + source_alert["startTime"],
|
start_time=datetime.strptime(source_alert["startDate"] + source_alert["startTime"],
|
||||||
"%Y-%m-%d%H:%M").replace(tzinfo=pytz.UTC).timestamp(),
|
"%Y-%m-%d%H:%M").replace(tzinfo=pytz.UTC).timestamp(),
|
||||||
end_time=datetime.strptime(source_alert["endDate"] + source_alert["endTime"],
|
end_time=datetime.strptime(source_alert["endDate"] + source_alert["endTime"],
|
||||||
|
|||||||
@@ -3,7 +3,6 @@ from datetime import datetime
|
|||||||
import pytz
|
import pytz
|
||||||
|
|
||||||
from alertproviders.http_alert_provider import HTTPAlertProvider
|
from alertproviders.http_alert_provider import HTTPAlertProvider
|
||||||
from core.sig_utils import get_icon_for_sig
|
|
||||||
from data.alert import Alert
|
from data.alert import Alert
|
||||||
|
|
||||||
|
|
||||||
@@ -29,7 +28,7 @@ class SOTA(HTTPAlertProvider):
|
|||||||
sig="SOTA",
|
sig="SOTA",
|
||||||
sig_refs=[source_alert["associationCode"] + "/" + source_alert["summitCode"]],
|
sig_refs=[source_alert["associationCode"] + "/" + source_alert["summitCode"]],
|
||||||
sig_refs_names=[source_alert["summitDetails"]],
|
sig_refs_names=[source_alert["summitDetails"]],
|
||||||
icon=get_icon_for_sig("SOTA"),
|
icon="mountain-sun",
|
||||||
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)
|
||||||
|
|||||||
@@ -1,64 +0,0 @@
|
|||||||
from datetime import datetime
|
|
||||||
|
|
||||||
import pytz
|
|
||||||
from rss_parser import RSSParser
|
|
||||||
|
|
||||||
from alertproviders.http_alert_provider import HTTPAlertProvider
|
|
||||||
from core.sig_utils import get_icon_for_sig
|
|
||||||
from data.alert import Alert
|
|
||||||
|
|
||||||
|
|
||||||
# Alert provider for Wainwrights on the Air
|
|
||||||
class WOTA(HTTPAlertProvider):
|
|
||||||
POLL_INTERVAL_SEC = 3600
|
|
||||||
ALERTS_URL = "https://www.wota.org.uk/alerts_rss.php"
|
|
||||||
RSS_DATE_TIME_FORMAT = "%a, %d %b %Y %H:%M:%S %z"
|
|
||||||
|
|
||||||
def __init__(self, provider_config):
|
|
||||||
super().__init__(provider_config, self.ALERTS_URL, self.POLL_INTERVAL_SEC)
|
|
||||||
|
|
||||||
def http_response_to_alerts(self, http_response):
|
|
||||||
new_alerts = []
|
|
||||||
rss = RSSParser.parse(http_response.content.decode())
|
|
||||||
# Iterate through source data
|
|
||||||
for source_alert in rss.channel.items:
|
|
||||||
|
|
||||||
# Reject GUID missing or zero
|
|
||||||
if not source_alert.guid or not source_alert.guid.content or source_alert.guid.content == "http://www.wota.org.uk/alerts/0":
|
|
||||||
continue
|
|
||||||
|
|
||||||
# Pick apart the title
|
|
||||||
title_split = source_alert.title.split(" on ")
|
|
||||||
dx_call = title_split[0]
|
|
||||||
ref = None
|
|
||||||
ref_name = None
|
|
||||||
if len(title_split) > 1:
|
|
||||||
ref_split = title_split[1].split(" - ")
|
|
||||||
ref = ref_split[0]
|
|
||||||
if len(ref_split) > 1:
|
|
||||||
ref_name = ref_split[1]
|
|
||||||
|
|
||||||
# Pick apart the description
|
|
||||||
desc_split = source_alert.description.split(". ")
|
|
||||||
freqs_modes = desc_split[0].replace("Frequencies/modes:", "").strip()
|
|
||||||
comment = None
|
|
||||||
if len(desc_split) > 1:
|
|
||||||
comment = desc_split[1].strip()
|
|
||||||
|
|
||||||
time = datetime.strptime(source_alert.pub_date.content, self.RSS_DATE_TIME_FORMAT).astimezone(pytz.UTC)
|
|
||||||
|
|
||||||
# Convert to our alert format
|
|
||||||
alert = Alert(source=self.name,
|
|
||||||
source_id=source_alert.guid.content,
|
|
||||||
dx_calls=[dx_call],
|
|
||||||
freqs_modes=freqs_modes,
|
|
||||||
comment=comment,
|
|
||||||
sig="WOTA",
|
|
||||||
sig_refs=[ref] if ref else [],
|
|
||||||
sig_refs_names=[ref_name] if ref_name else [],
|
|
||||||
icon=get_icon_for_sig("WOTA"),
|
|
||||||
start_time=time.timestamp())
|
|
||||||
|
|
||||||
# Add to our list.
|
|
||||||
new_alerts.append(alert)
|
|
||||||
return new_alerts
|
|
||||||
@@ -3,7 +3,6 @@ from datetime import datetime
|
|||||||
import pytz
|
import pytz
|
||||||
|
|
||||||
from alertproviders.http_alert_provider import HTTPAlertProvider
|
from alertproviders.http_alert_provider import HTTPAlertProvider
|
||||||
from core.sig_utils import get_icon_for_sig
|
|
||||||
from data.alert import Alert
|
from data.alert import Alert
|
||||||
|
|
||||||
|
|
||||||
@@ -27,7 +26,7 @@ class WWFF(HTTPAlertProvider):
|
|||||||
comment=source_alert["remarks"],
|
comment=source_alert["remarks"],
|
||||||
sig="WWFF",
|
sig="WWFF",
|
||||||
sig_refs=[source_alert["reference"]],
|
sig_refs=[source_alert["reference"]],
|
||||||
icon=get_icon_for_sig("WWFF"),
|
icon="seedling",
|
||||||
start_time=datetime.strptime(source_alert["utc_start"],
|
start_time=datetime.strptime(source_alert["utc_start"],
|
||||||
"%Y-%m-%d %H:%M:%S").replace(tzinfo=pytz.UTC).timestamp(),
|
"%Y-%m-%d %H:%M:%S").replace(tzinfo=pytz.UTC).timestamp(),
|
||||||
end_time=datetime.strptime(source_alert["utc_end"],
|
end_time=datetime.strptime(source_alert["utc_end"],
|
||||||
|
|||||||
@@ -41,14 +41,6 @@ spot-providers:
|
|||||||
class: "ParksNPeaks"
|
class: "ParksNPeaks"
|
||||||
name: "ParksNPeaks"
|
name: "ParksNPeaks"
|
||||||
enabled: true
|
enabled: true
|
||||||
-
|
|
||||||
class: "ZLOTA"
|
|
||||||
name: "ZLOTA"
|
|
||||||
enabled: true
|
|
||||||
-
|
|
||||||
class: "WOTA"
|
|
||||||
name: "WOTA"
|
|
||||||
enabled: true
|
|
||||||
-
|
-
|
||||||
class: "APRSIS"
|
class: "APRSIS"
|
||||||
name: "APRS-IS"
|
name: "APRS-IS"
|
||||||
@@ -92,14 +84,6 @@ alert-providers:
|
|||||||
class: "WWFF"
|
class: "WWFF"
|
||||||
name: "WWFF"
|
name: "WWFF"
|
||||||
enabled: true
|
enabled: true
|
||||||
-
|
|
||||||
class: "ParksNPeaks"
|
|
||||||
name: "ParksNPeaks"
|
|
||||||
enabled: true
|
|
||||||
-
|
|
||||||
class: "WOTA"
|
|
||||||
name: "WOTA"
|
|
||||||
enabled: true
|
|
||||||
-
|
-
|
||||||
class: "NG3K"
|
class: "NG3K"
|
||||||
name: "NG3K"
|
name: "NG3K"
|
||||||
|
|||||||
@@ -1,39 +1,20 @@
|
|||||||
from core.config import SERVER_OWNER_CALLSIGN
|
from core.config import SERVER_OWNER_CALLSIGN
|
||||||
from data.band import Band
|
from data.band import Band
|
||||||
from data.sig import SIG
|
|
||||||
|
|
||||||
# General software
|
# General software
|
||||||
SOFTWARE_NAME = "Spothole by M0TRT"
|
SOFTWARE_NAME = "Spothole by M0TRT"
|
||||||
SOFTWARE_VERSION = "0.1"
|
SOFTWARE_VERSION = "0.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 + " " + SOFTWARE_VERSION + " (operated by " + SERVER_OWNER_CALLSIGN + ")"}
|
||||||
|
|
||||||
# Special Interest Groups
|
# Special Interest Groups
|
||||||
SIGS = [
|
SIGS = ["POTA", "SOTA", "WWFF", "GMA", "WWBOTA", "HEMA", "MOTA", "ARLHS", "SiOTA", "WCA"]
|
||||||
SIG(name="POTA", description="Parks on the Air", icon="tree", ref_regex=r"[A-Z]{2}\-\d+"),
|
|
||||||
SIG(name="SOTA", description="Summits on the Air", icon="mountain-sun", ref_regex=r"[A-Z0-9]{1,3}\/[A-Z]{2}\-\d+"),
|
|
||||||
SIG(name="WWFF", description="World Wide Flora & Fauna", icon="seedling", ref_regex=r"[A-Z0-9]{1,3}FF\-\d+"),
|
|
||||||
SIG(name="GMA", description="Global Mountain Activity", icon="person-hiking", ref_regex=r"[A-Z0-9]{1,3}\/[A-Z]{2}\-\d+"),
|
|
||||||
SIG(name="WWBOTA", description="Worldwide Bunkers on the Air", icon="radiation", ref_regex=r"B\/[A-Z0-9]{1,3}\-\d+"),
|
|
||||||
SIG(name="HEMA", description="HuMPs Excluding Marilyns Award", icon="mound", ref_regex=r"[A-Z0-9]{1,3}\/[A-Z]{3}\-\d+"),
|
|
||||||
SIG(name="IOTA", description="Islands on the Air", icon="umbrella-beach", ref_regex=r"[A-Z]{2}\-\d+"),
|
|
||||||
SIG(name="MOTA", description="Mills on the Air", icon="fan", 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+"),
|
|
||||||
SIG(name="ILLW", description="International Lighthouse & Lightship Weekend", icon="tower-observation", 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="WCA", description="World Castles Award", icon="chess-rook", ref_regex=r"[A-Z0-9]{1,3}\-\d+"),
|
|
||||||
SIG(name="ZLOTA", description="New Zealand on the Air", icon="kiwi-bird", ref_regex=r"ZL[A-Z]/[A-Z]{2}\-\d+"),
|
|
||||||
SIG(name="KRMNPA", description="Keith Roget Memorial National Parks Award", icon="earth-oceania", ref_regex=r""),
|
|
||||||
SIG(name="WAB", description="Worked All Britain", icon="table-cells-large", ref_regex=r"[A-Z]{1,2}[0-9]{2}"),
|
|
||||||
SIG(name="WAI", description="Worked All Ireland", icon="table-cells-large", ref_regex=r"[A-Z][0-9]{2}"),
|
|
||||||
SIG(name="WOTA", description="Wainwrights on the Air", icon="w", ref_regex=r"[A-Z]{3}-[0-9]{2}")
|
|
||||||
]
|
|
||||||
|
|
||||||
# Modes. Note "DIGI" and "DIGITAL" are also supported but are normalised into "DATA".
|
# Modes. Note "DIGI" and "DIGITAL" are also supported but are normalised into "DATA".
|
||||||
CW_MODES = ["CW"]
|
CW_MODES = ["CW"]
|
||||||
PHONE_MODES = ["PHONE", "SSB", "USB", "LSB", "AM", "FM", "DV", "DMR", "DSTAR", "C4FM", "M17"]
|
PHONE_MODES = ["PHONE", "SSB", "USB", "LSB", "AM", "FM", "DV", "DMR", "DSTAR", "C4FM", "M17"]
|
||||||
DATA_MODES = ["DATA", "FT8", "FT4", "RTTY", "SSTV", "JS8", "HELL", "BPSK", "PSK", "PSK31", "BPSK31", "OLIVIA", "MFSK", "MFSK32"]
|
DATA_MODES = ["DATA", "FT8", "FT4", "RTTY", "SSTV", "JS8", "HELL", "BPSK", "PSK", "PSK31", "BPSK31", "OLIVIA"]
|
||||||
ALL_MODES = CW_MODES + PHONE_MODES + DATA_MODES
|
ALL_MODES = CW_MODES + PHONE_MODES + DATA_MODES
|
||||||
MODE_TYPES = ["CW", "PHONE", "DATA"]
|
MODE_TYPES = ["CW", "PHONE", "DATA"]
|
||||||
|
|
||||||
|
|||||||
@@ -1,103 +0,0 @@
|
|||||||
import logging
|
|
||||||
import re
|
|
||||||
from math import floor
|
|
||||||
|
|
||||||
from pyproj import Transformer
|
|
||||||
|
|
||||||
TRANSFORMER_OS_GRID_TO_WGS84 = Transformer.from_crs("EPSG:27700", "EPSG:4326")
|
|
||||||
TRANSFORMER_IRISH_GRID_TO_WGS84 = Transformer.from_crs("EPSG:29903", "EPSG:4326")
|
|
||||||
TRANSFORMER_CI_UTM_GRID_TO_WGS84 = Transformer.from_crs("+proj=utm +zone=30 +ellps=WGS84", "EPSG:4326")
|
|
||||||
|
|
||||||
|
|
||||||
# Convert a Worked All Britain or Worked All Ireland reference to a lat/lon point.
|
|
||||||
def wab_wai_square_to_lat_lon(ref):
|
|
||||||
# First check we have a valid grid square, and based on what it looks like, use either the Ordnance Survey, Irish,
|
|
||||||
# or UTM grid systems to perform the conversion.
|
|
||||||
if re.match(r"^[HNOST][ABCDEFGHJKLMNOPQRSTUVWXYZ][0-9]{2}$", ref):
|
|
||||||
return os_grid_square_to_lat_lon(ref)
|
|
||||||
elif re.match(r"^[ABCDEFGHJKLMNOPQRSTUVWXYZ][0-9]{2}$", ref):
|
|
||||||
return irish_grid_square_to_lat_lon(ref)
|
|
||||||
elif re.match(r"^W[AV][0-9]{2}$", ref):
|
|
||||||
return utm_grid_square_to_lat_lon(ref)
|
|
||||||
else:
|
|
||||||
logging.warn("Invalid WAB/WAI square: " + ref)
|
|
||||||
return None
|
|
||||||
|
|
||||||
|
|
||||||
# Get a lat/lon point for the centre of an Ordnance Survey grid square
|
|
||||||
def os_grid_square_to_lat_lon(ref):
|
|
||||||
# Convert the letters into multipliers for the 500km squares and 100km squares
|
|
||||||
offset_500km_multiplier = ord(ref[0]) - 65
|
|
||||||
offset_100km_multiplier = ord(ref[1]) - 65
|
|
||||||
|
|
||||||
# The letter "I" is not used in the grid, so any offset of 8 or more needs to be reduced by 1.
|
|
||||||
if offset_500km_multiplier >= 8:
|
|
||||||
offset_500km_multiplier = offset_500km_multiplier - 1
|
|
||||||
if offset_100km_multiplier >= 8:
|
|
||||||
offset_100km_multiplier = offset_100km_multiplier - 1
|
|
||||||
|
|
||||||
# Convert the offsets into increments of 100km from the false origin (grid square SV):
|
|
||||||
easting_100km = ((offset_500km_multiplier - 2) % 5) * 5 + (offset_100km_multiplier % 5)
|
|
||||||
northing_100km = (19 - floor(offset_500km_multiplier / 5) * 5) - floor(offset_100km_multiplier / 5)
|
|
||||||
|
|
||||||
# Take the numeric parts of the grid square and multiply by 10000 to get metres, then combine with the 100km
|
|
||||||
# box offsets
|
|
||||||
easting = int(ref[2]) * 10000 + easting_100km * 100000
|
|
||||||
northing = int(ref[3]) * 10000 + northing_100km * 100000
|
|
||||||
|
|
||||||
# Add 5000m to each value to get the middle of the box rather than the south-west corner
|
|
||||||
easting = easting + 5000
|
|
||||||
northing = northing + 5000
|
|
||||||
|
|
||||||
# Reproject to WGS84 lat/lon
|
|
||||||
lat, lon = TRANSFORMER_OS_GRID_TO_WGS84.transform(easting, northing)
|
|
||||||
return lat, lon
|
|
||||||
|
|
||||||
|
|
||||||
# Get a lat/lon point for the centre of an Irish Grid square.
|
|
||||||
def irish_grid_square_to_lat_lon(ref):
|
|
||||||
# Convert the letters into multipliers for the 100km squares
|
|
||||||
offset_100km_multiplier = ord(ref[0]) - 65
|
|
||||||
|
|
||||||
# The letter "I" is not used in the grid, so any offset of 8 or more needs to be reduced by 1.
|
|
||||||
if offset_100km_multiplier >= 8:
|
|
||||||
offset_100km_multiplier = offset_100km_multiplier - 1
|
|
||||||
|
|
||||||
# Convert the offsets into increments of 100km from the false origin:
|
|
||||||
easting_100km = offset_100km_multiplier % 5
|
|
||||||
northing_100km = 4 - floor(offset_100km_multiplier / 5)
|
|
||||||
|
|
||||||
# Take the numeric parts of the grid square and multiply by 10000 to get metres, then combine with the 100km
|
|
||||||
# box offsets
|
|
||||||
easting = int(ref[1]) * 10000 + easting_100km * 100000
|
|
||||||
northing = int(ref[2]) * 10000 + northing_100km * 100000
|
|
||||||
|
|
||||||
# Add 5000m to each value to get the middle of the box rather than the south-west corner
|
|
||||||
easting = easting + 5000
|
|
||||||
northing = northing + 5000
|
|
||||||
|
|
||||||
# Reproject to WGS84 lat/lon
|
|
||||||
lat, lon = TRANSFORMER_IRISH_GRID_TO_WGS84.transform(easting, northing)
|
|
||||||
return lat, lon
|
|
||||||
|
|
||||||
|
|
||||||
# Get a lat/lon point for the centre of a UTM grid square (supports only squares WA & WV for the Channel Islands, nothing else implemented)
|
|
||||||
def utm_grid_square_to_lat_lon(ref):
|
|
||||||
# Take the numeric parts of the grid square and multiply by 10000 to get metres from the corner of the letter-based grid square
|
|
||||||
easting = int(ref[2]) * 10000
|
|
||||||
northing = int(ref[3]) * 10000
|
|
||||||
|
|
||||||
# Apply the appropriate offset based on whether the square is WA or WV
|
|
||||||
easting = easting + 500000
|
|
||||||
if ref[1] == "A":
|
|
||||||
northing = northing + 5500000
|
|
||||||
else:
|
|
||||||
northing = northing + 5400000
|
|
||||||
|
|
||||||
# Add 5000m to each value to get the middle of the box rather than the south-west corner
|
|
||||||
easting = easting + 5000
|
|
||||||
northing = northing + 5000
|
|
||||||
|
|
||||||
# Reproject to WGS84 lat/lon
|
|
||||||
lat, lon = TRANSFORMER_CI_UTM_GRID_TO_WGS84.transform(easting, northing)
|
|
||||||
return lat, lon
|
|
||||||
@@ -3,7 +3,7 @@ import logging
|
|||||||
from datetime import timedelta
|
from datetime import timedelta
|
||||||
|
|
||||||
from diskcache import Cache
|
from diskcache import Cache
|
||||||
from pyhamtools import LookupLib, Callinfo, callinfo
|
from pyhamtools import LookupLib, Callinfo
|
||||||
from pyhamtools.exceptions import APIKeyMissingError
|
from pyhamtools.exceptions import APIKeyMissingError
|
||||||
from pyhamtools.frequency import freq_to_band
|
from pyhamtools.frequency import freq_to_band
|
||||||
from pyhamtools.locator import latlong_to_locator
|
from pyhamtools.locator import latlong_to_locator
|
||||||
@@ -266,46 +266,36 @@ class LookupHelper:
|
|||||||
# Utility method to get QRZ.com data from cache if possible, if not get it from the API and cache it
|
# Utility method to get QRZ.com data from cache if possible, if not get it from the API and cache it
|
||||||
def get_qrz_data_for_callsign(self, call):
|
def get_qrz_data_for_callsign(self, call):
|
||||||
# Fetch from cache if we can, otherwise fetch from the API and cache it
|
# Fetch from cache if we can, otherwise fetch from the API and cache it
|
||||||
if call in self.QRZ_CALLSIGN_DATA_CACHE:
|
qrz_data = self.QRZ_CALLSIGN_DATA_CACHE.get(call)
|
||||||
return self.QRZ_CALLSIGN_DATA_CACHE.get(call)
|
if qrz_data:
|
||||||
|
return qrz_data
|
||||||
elif self.QRZ_AVAILABLE:
|
elif self.QRZ_AVAILABLE:
|
||||||
try:
|
try:
|
||||||
data = self.LOOKUP_LIB_QRZ.lookup_callsign(callsign=call)
|
data = self.LOOKUP_LIB_QRZ.lookup_callsign(callsign=call)
|
||||||
self.QRZ_CALLSIGN_DATA_CACHE.add(call, data, expire=604800) # 1 week in seconds
|
self.QRZ_CALLSIGN_DATA_CACHE.add(call, data, expire=604800) # 1 week in seconds
|
||||||
return data
|
return data
|
||||||
except KeyError:
|
except KeyError:
|
||||||
# QRZ had no info for the call, but maybe it had prefixes or suffixes. Try again with the base call.
|
# QRZ had no info for the call, that's OK. Cache a None so we don't try to look this up again
|
||||||
try:
|
self.QRZ_CALLSIGN_DATA_CACHE.add(call, None, expire=604800) # 1 week in seconds
|
||||||
data = self.LOOKUP_LIB_QRZ.lookup_callsign(callsign=callinfo.Callinfo.get_homecall(call))
|
return None
|
||||||
self.QRZ_CALLSIGN_DATA_CACHE.add(call, data, expire=604800) # 1 week in seconds
|
|
||||||
return data
|
|
||||||
except KeyError:
|
|
||||||
# QRZ had no info for the call, that's OK. Cache a None so we don't try to look this up again
|
|
||||||
self.QRZ_CALLSIGN_DATA_CACHE.add(call, None, expire=604800) # 1 week in seconds
|
|
||||||
return None
|
|
||||||
else:
|
else:
|
||||||
return None
|
return None
|
||||||
|
|
||||||
# Utility method to get Clublog API data from cache if possible, if not get it from the API and cache it
|
# Utility method to get Clublog API data from cache if possible, if not get it from the API and cache it
|
||||||
def get_clublog_api_data_for_callsign(self, call):
|
def get_clublog_api_data_for_callsign(self, call):
|
||||||
# Fetch from cache if we can, otherwise fetch from the API and cache it
|
# Fetch from cache if we can, otherwise fetch from the API and cache it
|
||||||
if call in self.CLUBLOG_CALLSIGN_DATA_CACHE:
|
clublog_data = self.CLUBLOG_CALLSIGN_DATA_CACHE.get(call)
|
||||||
return self.CLUBLOG_CALLSIGN_DATA_CACHE.get(call)
|
if clublog_data:
|
||||||
|
return clublog_data
|
||||||
elif self.CLUBLOG_API_AVAILABLE:
|
elif self.CLUBLOG_API_AVAILABLE:
|
||||||
try:
|
try:
|
||||||
data = self.LOOKUP_LIB_CLUBLOG_API.lookup_callsign(callsign=call)
|
data = self.LOOKUP_LIB_CLUBLOG_API.lookup_callsign(callsign=call)
|
||||||
self.CLUBLOG_CALLSIGN_DATA_CACHE.add(call, data, expire=604800) # 1 week in seconds
|
self.CLUBLOG_CALLSIGN_DATA_CACHE.add(call, data, expire=604800) # 1 week in seconds
|
||||||
return data
|
return data
|
||||||
except KeyError:
|
except KeyError:
|
||||||
# Clublog had no info for the call, but maybe it had prefixes or suffixes. Try again with the base call.
|
# Clublog had no info for the call, that's OK. Cache a None so we don't try to look this up again
|
||||||
try:
|
self.CLUBLOG_CALLSIGN_DATA_CACHE.add(call, None, expire=604800) # 1 week in seconds
|
||||||
data = self.LOOKUP_LIB_CLUBLOG_API.lookup_callsign(callsign=callinfo.Callinfo.get_homecall(call))
|
return None
|
||||||
self.CLUBLOG_CALLSIGN_DATA_CACHE.add(call, data, expire=604800) # 1 week in seconds
|
|
||||||
return data
|
|
||||||
except KeyError:
|
|
||||||
# Clublog had no info for the call, that's OK. Cache a None so we don't try to look this up again
|
|
||||||
self.CLUBLOG_CALLSIGN_DATA_CACHE.add(call, None, expire=604800) # 1 week in seconds
|
|
||||||
return None
|
|
||||||
except APIKeyMissingError:
|
except APIKeyMissingError:
|
||||||
# User API key was wrong, warn
|
# User API key was wrong, warn
|
||||||
logging.error("Could not look up via Clublog API, key " + self.CLUBLOG_API_KEY + " was rejected.")
|
logging.error("Could not look up via Clublog API, key " + self.CLUBLOG_API_KEY + " was rejected.")
|
||||||
|
|||||||
@@ -1,21 +0,0 @@
|
|||||||
from core.constants import SIGS
|
|
||||||
|
|
||||||
# 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.
|
|
||||||
def get_ref_regex_for_sig(sig):
|
|
||||||
for s in SIGS:
|
|
||||||
if s.name == sig:
|
|
||||||
return s.ref_regex
|
|
||||||
return None
|
|
||||||
|
|
||||||
# Regex matching any SIG
|
|
||||||
ANY_SIG_REGEX = r"(" + r"|".join(list(map(lambda p: p.name, SIGS))) + r")"
|
|
||||||
|
|
||||||
# Regex matching any SIG reference
|
|
||||||
ANY_XOTA_SIG_REF_REGEX = r"[\w\/]+\-\d+"
|
|
||||||
@@ -1,7 +1,6 @@
|
|||||||
import copy
|
import copy
|
||||||
import hashlib
|
import hashlib
|
||||||
import json
|
import json
|
||||||
import re
|
|
||||||
from dataclasses import dataclass
|
from dataclasses import dataclass
|
||||||
from datetime import datetime, timedelta
|
from datetime import datetime, timedelta
|
||||||
|
|
||||||
@@ -9,7 +8,6 @@ import pytz
|
|||||||
|
|
||||||
from core.constants import DXCC_FLAGS
|
from core.constants import DXCC_FLAGS
|
||||||
from core.lookup_helper import lookup_helper
|
from core.lookup_helper import lookup_helper
|
||||||
from core.sig_utils import get_icon_for_sig
|
|
||||||
|
|
||||||
|
|
||||||
# Data class that defines an alert.
|
# Data class that defines an alert.
|
||||||
@@ -60,7 +58,7 @@ class Alert:
|
|||||||
# Activation score. SOTA only
|
# Activation score. SOTA only
|
||||||
activation_score: int = None
|
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, 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
|
icon: str = "question"
|
||||||
# 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"...
|
||||||
@@ -101,10 +99,6 @@ class Alert:
|
|||||||
if self.dx_dxcc_id and self.dx_dxcc_id in DXCC_FLAGS and not self.dx_flag:
|
if self.dx_dxcc_id and self.dx_dxcc_id in DXCC_FLAGS and not self.dx_flag:
|
||||||
self.dx_flag = DXCC_FLAGS[self.dx_dxcc_id]
|
self.dx_flag = DXCC_FLAGS[self.dx_dxcc_id]
|
||||||
|
|
||||||
# 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.
|
||||||
|
|||||||
14
data/sig.py
14
data/sig.py
@@ -1,14 +0,0 @@
|
|||||||
from dataclasses import dataclass
|
|
||||||
|
|
||||||
# Data class that defines a Special Interest Group.
|
|
||||||
@dataclass
|
|
||||||
class SIG:
|
|
||||||
# SIG name, e.g. "POTA"
|
|
||||||
name: str
|
|
||||||
# Description, e.g. "Parks on the Air"
|
|
||||||
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+".
|
|
||||||
ref_regex: str
|
|
||||||
53
data/spot.py
53
data/spot.py
@@ -2,7 +2,6 @@ import copy
|
|||||||
import hashlib
|
import hashlib
|
||||||
import json
|
import json
|
||||||
import logging
|
import logging
|
||||||
import re
|
|
||||||
from dataclasses import dataclass
|
from dataclasses import dataclass
|
||||||
from datetime import datetime
|
from datetime import datetime
|
||||||
|
|
||||||
@@ -10,9 +9,7 @@ import pytz
|
|||||||
from pyhamtools.locator import locator_to_latlong, latlong_to_locator
|
from pyhamtools.locator import locator_to_latlong, latlong_to_locator
|
||||||
|
|
||||||
from core.constants import DXCC_FLAGS
|
from core.constants import DXCC_FLAGS
|
||||||
from core.geo_utils import wab_wai_square_to_lat_lon
|
|
||||||
from core.lookup_helper import lookup_helper
|
from core.lookup_helper import lookup_helper
|
||||||
from core.sig_utils import get_icon_for_sig
|
|
||||||
|
|
||||||
|
|
||||||
# Data class that defines a spot.
|
# Data class that defines a spot.
|
||||||
@@ -21,6 +18,7 @@ class Spot:
|
|||||||
# Unique identifier for the spot
|
# Unique identifier for the spot
|
||||||
id: str = None
|
id: str = None
|
||||||
|
|
||||||
|
|
||||||
# DX (spotted) operator info
|
# DX (spotted) operator info
|
||||||
|
|
||||||
# Callsign of the operator that has been spotted
|
# Callsign of the operator that has been spotted
|
||||||
@@ -51,13 +49,12 @@ class Spot:
|
|||||||
# lookup
|
# lookup
|
||||||
dx_latitude: float = None
|
dx_latitude: float = None
|
||||||
dx_longitude: float = None
|
dx_longitude: float = None
|
||||||
# DX Location source. Indicates how accurate the location might be. Values: "SPOT", "WAB/WAI GRID", "QRZ", "DXCC", "NONE"
|
# DX Location source. Indicates how accurate the location might be. Values: "SPOT", "QRZ, "DXCC", "NONE"
|
||||||
dx_location_source: str = "NONE"
|
dx_location_source: str = "NONE"
|
||||||
# DX Location good. Indicates that the software thinks the location data is good enough to plot on a map. This is
|
# DX Location good. Indicates that the software thinks the location data is good enough to plot on a map.
|
||||||
# true if the location source is "SPOT" or "WAB/WAI GRID", or if the location source is "QRZ" and the DX callsign
|
|
||||||
# doesn't have a suffix like /P.
|
|
||||||
dx_location_good: bool = False
|
dx_location_good: bool = False
|
||||||
|
|
||||||
|
|
||||||
# DE (Spotter) info
|
# DE (Spotter) info
|
||||||
|
|
||||||
# Callsign of the spotter
|
# Callsign of the spotter
|
||||||
@@ -78,6 +75,7 @@ class Spot:
|
|||||||
de_latitude: float = None
|
de_latitude: float = None
|
||||||
de_longitude: float = None
|
de_longitude: float = None
|
||||||
|
|
||||||
|
|
||||||
# General QSO info
|
# General QSO info
|
||||||
|
|
||||||
# Reported mode, such as SSB, PHONE, CW, FT8...
|
# Reported mode, such as SSB, PHONE, CW, FT8...
|
||||||
@@ -95,6 +93,7 @@ class Spot:
|
|||||||
# QRT state. Some APIs return spots marked as QRT. Otherwise we can check the comments.
|
# QRT state. Some APIs return spots marked as QRT. Otherwise we can check the comments.
|
||||||
qrt: bool = False
|
qrt: bool = False
|
||||||
|
|
||||||
|
|
||||||
# Special Interest Group info
|
# Special Interest Group info
|
||||||
|
|
||||||
# Special Interest Group (SIG), e.g. outdoor activity programme such as POTA
|
# Special Interest Group (SIG), e.g. outdoor activity programme such as POTA
|
||||||
@@ -103,21 +102,21 @@ class Spot:
|
|||||||
sig_refs: list = None
|
sig_refs: list = None
|
||||||
# SIG reference names
|
# SIG reference names
|
||||||
sig_refs_names: list = None
|
sig_refs_names: list = None
|
||||||
# SIG reference URLs
|
|
||||||
sig_refs_urls: list = None
|
|
||||||
# Activation score. SOTA only
|
# Activation score. SOTA only
|
||||||
activation_score: int = None
|
activation_score: int = None
|
||||||
|
|
||||||
|
|
||||||
# Display guidance (optional)
|
# Display guidance (optional)
|
||||||
|
|
||||||
# Icon, from the Font Awesome set. This is fairly opinionated but is here to help the Spothole web UI and Field
|
# 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.
|
# Spotter. Does not include the "fa-" prefix.
|
||||||
icon: str = None
|
icon: str = "question"
|
||||||
# Colour to represent this spot, if a client chooses to colour spots based on their frequency band, using PSK
|
# 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.
|
# 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_color: str = None
|
||||||
band_contrast_color: str = None
|
band_contrast_color: str = None
|
||||||
|
|
||||||
|
|
||||||
# Timing info
|
# Timing info
|
||||||
|
|
||||||
# Time of the spot, UTC seconds since UNIX epoch
|
# Time of the spot, UTC seconds since UNIX epoch
|
||||||
@@ -131,6 +130,7 @@ class Spot:
|
|||||||
# Time that this software received the spot, ISO 8601
|
# Time that this software received the spot, ISO 8601
|
||||||
received_time_iso: str = None
|
received_time_iso: str = None
|
||||||
|
|
||||||
|
|
||||||
# Source info
|
# Source info
|
||||||
|
|
||||||
# Where we got the spot from, e.g. "POTA", "Cluster"...
|
# Where we got the spot from, e.g. "POTA", "Cluster"...
|
||||||
@@ -177,20 +177,6 @@ class Spot:
|
|||||||
if self.de_call and "-" in self.de_call:
|
if self.de_call and "-" in self.de_call:
|
||||||
self.de_call = self.de_call.split("-")[0]
|
self.de_call = self.de_call.split("-")[0]
|
||||||
|
|
||||||
# If we have a spotter of "RBNHOLE", we should have the actual spotter callsign in the comment, so extract it.
|
|
||||||
# RBNHole posts come from a number of providers, so it's dealt with here in the generic spot handling code.
|
|
||||||
if self.de_call == "RBNHOLE" and self.comment:
|
|
||||||
rbnhole_call_match = re.search(r"\Wat ([a-z0-9/]+)\W", self.comment, re.IGNORECASE)
|
|
||||||
if rbnhole_call_match:
|
|
||||||
self.de_call = rbnhole_call_match.group(1).upper()
|
|
||||||
|
|
||||||
# If we have a spotter of "SOTAMAT", we might have the actual spotter callsign in the comment, if so extract it.
|
|
||||||
# SOTAMAT can do POTA as well as SOTA, so it's dealt with here in the generic spot handling code.
|
|
||||||
if self.de_call == "SOTAMAT" and self.comment:
|
|
||||||
sotamat_call_match = re.search(r"\Wfrom ([a-z0-9/]+)]", self.comment, re.IGNORECASE)
|
|
||||||
if sotamat_call_match:
|
|
||||||
self.de_call = sotamat_call_match.group(1).upper()
|
|
||||||
|
|
||||||
# Spotter country, continent, zones etc. from callsign.
|
# Spotter country, continent, zones etc. from callsign.
|
||||||
# DE of "RBNHOLE" and "SOTAMAT" are not things we can look up location for
|
# DE of "RBNHOLE" and "SOTAMAT" are not things we can look up location for
|
||||||
if self.de_call != "RBNHOLE" and self.de_call != "SOTAMAT":
|
if self.de_call != "RBNHOLE" and self.de_call != "SOTAMAT":
|
||||||
@@ -229,10 +215,6 @@ 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)
|
||||||
|
|
||||||
# Icon from SIG
|
|
||||||
if self.sig and not self.icon:
|
|
||||||
self.icon = get_icon_for_sig(self.sig)
|
|
||||||
|
|
||||||
# DX Grid to lat/lon and vice versa
|
# DX Grid to lat/lon and vice versa
|
||||||
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)
|
||||||
@@ -246,19 +228,6 @@ class Spot:
|
|||||||
if self.dx_latitude:
|
if self.dx_latitude:
|
||||||
self.dx_location_source = "SPOT"
|
self.dx_location_source = "SPOT"
|
||||||
|
|
||||||
# WAB/WAI grid to lat/lon
|
|
||||||
if not self.dx_latitude and self.sig and self.sig_refs and len(self.sig_refs) > 0 and (
|
|
||||||
self.sig == "WAB" or self.sig == "WAI"):
|
|
||||||
ll = wab_wai_square_to_lat_lon(self.sig_refs[0])
|
|
||||||
if ll:
|
|
||||||
self.dx_latitude = ll[0]
|
|
||||||
self.dx_longitude = ll[1]
|
|
||||||
try:
|
|
||||||
self.dx_grid = latlong_to_locator(self.dx_latitude, self.dx_longitude, 8)
|
|
||||||
except:
|
|
||||||
logging.debug("Invalid lat/lon received from WAB/WAI grid")
|
|
||||||
self.dx_location_source = "WAB/WAI GRID"
|
|
||||||
|
|
||||||
# QRT comment detection
|
# QRT comment detection
|
||||||
if self.comment and not self.qrt:
|
if self.comment and not self.qrt:
|
||||||
self.qrt = "QRT" in self.comment.upper()
|
self.qrt = "QRT" in self.comment.upper()
|
||||||
@@ -287,7 +256,7 @@ class Spot:
|
|||||||
|
|
||||||
# DX Location is "good" if it is from a spot, or from QRZ if the callsign doesn't contain a slash, so the operator
|
# DX Location is "good" if it is from a spot, or from QRZ if the callsign doesn't contain a slash, so the operator
|
||||||
# is likely at home.
|
# is likely at home.
|
||||||
self.dx_location_good = self.dx_location_source == "SPOT" or self.dx_location_source == "WAB/WAI GRID" or (
|
self.dx_location_good = self.dx_location_source == "SPOT" or (
|
||||||
self.dx_location_source == "QRZ" and not "/" in self.dx_call)
|
self.dx_location_source == "QRZ" and not "/" in self.dx_call)
|
||||||
|
|
||||||
# DE of "RBNHOLE" and "SOTAMAT" are not things we can look up location for
|
# DE of "RBNHOLE" and "SOTAMAT" are not things we can look up location for
|
||||||
|
|||||||
Binary file not shown.
|
Before Width: | Height: | Size: 173 KiB |
@@ -10,4 +10,3 @@ diskcache~=5.6.3
|
|||||||
psutil~=7.1.0
|
psutil~=7.1.0
|
||||||
requests-sse~=0.5.2
|
requests-sse~=0.5.2
|
||||||
rss-parser~=2.1.1
|
rss-parser~=2.1.1
|
||||||
pyproj~=3.7.2
|
|
||||||
@@ -31,15 +31,14 @@ class WebServer:
|
|||||||
bottle.BaseTemplate.defaults['software_version'] = SOFTWARE_VERSION
|
bottle.BaseTemplate.defaults['software_version'] = SOFTWARE_VERSION
|
||||||
|
|
||||||
# Routes for API calls
|
# Routes for API calls
|
||||||
bottle.get("/api/v1/spots")(lambda: self.serve_spots_api())
|
bottle.get("/api/v1/spots")(lambda: self.serve_api(self.get_spot_list_with_filters()))
|
||||||
bottle.get("/api/v1/alerts")(lambda: self.serve_alerts_api())
|
bottle.get("/api/v1/alerts")(lambda: self.serve_api(self.get_alert_list_with_filters()))
|
||||||
bottle.get("/api/v1/options")(lambda: self.serve_api(self.get_options()))
|
bottle.get("/api/v1/options")(lambda: self.serve_api(self.get_options()))
|
||||||
bottle.get("/api/v1/status")(lambda: self.serve_api(self.status_data))
|
bottle.get("/api/v1/status")(lambda: self.serve_api(self.status_data))
|
||||||
bottle.post("/api/v1/spot")(lambda: self.accept_spot())
|
bottle.post("/api/v1/spot")(lambda: self.accept_spot())
|
||||||
# Routes for templated pages
|
# Routes for templated pages
|
||||||
bottle.get("/")(lambda: self.serve_template('webpage_spots'))
|
bottle.get("/")(lambda: self.serve_template('webpage_spots'))
|
||||||
bottle.get("/map")(lambda: self.serve_template('webpage_map'))
|
bottle.get("/map")(lambda: self.serve_template('webpage_map'))
|
||||||
bottle.get("/bands")(lambda: self.serve_template('webpage_bands'))
|
|
||||||
bottle.get("/alerts")(lambda: self.serve_template('webpage_alerts'))
|
bottle.get("/alerts")(lambda: self.serve_template('webpage_alerts'))
|
||||||
bottle.get("/status")(lambda: self.serve_template('webpage_status'))
|
bottle.get("/status")(lambda: self.serve_template('webpage_status'))
|
||||||
bottle.get("/about")(lambda: self.serve_template('webpage_about'))
|
bottle.get("/about")(lambda: self.serve_template('webpage_about'))
|
||||||
@@ -57,38 +56,6 @@ class WebServer:
|
|||||||
self.status = "Waiting"
|
self.status = "Waiting"
|
||||||
run(host='localhost', port=self.port)
|
run(host='localhost', port=self.port)
|
||||||
|
|
||||||
# Serve the JSON API /spots endpoint
|
|
||||||
def serve_spots_api(self):
|
|
||||||
try:
|
|
||||||
data = self.get_spot_list_with_filters()
|
|
||||||
return self.serve_api(data)
|
|
||||||
except ValueError as e:
|
|
||||||
logging.error(e)
|
|
||||||
response.content_type = 'application/json'
|
|
||||||
response.status = 400
|
|
||||||
return json.dumps("Bad request - " + str(e), default=serialize_everything)
|
|
||||||
except Exception as e:
|
|
||||||
logging.error(e)
|
|
||||||
response.content_type = 'application/json'
|
|
||||||
response.status = 500
|
|
||||||
return json.dumps("Error - " + str(e), default=serialize_everything)
|
|
||||||
|
|
||||||
# Serve the JSON API /alerts endpoint
|
|
||||||
def serve_alerts_api(self):
|
|
||||||
try:
|
|
||||||
data = self.get_alert_list_with_filters()
|
|
||||||
return self.serve_api(data)
|
|
||||||
except ValueError as e:
|
|
||||||
logging.error(e)
|
|
||||||
response.content_type = 'application/json'
|
|
||||||
response.status = 400
|
|
||||||
return json.dumps("Bad request - " + str(e), default=serialize_everything)
|
|
||||||
except Exception as e:
|
|
||||||
logging.error(e)
|
|
||||||
response.content_type = 'application/json'
|
|
||||||
response.status = 500
|
|
||||||
return json.dumps("Error - " + str(e), default=serialize_everything)
|
|
||||||
|
|
||||||
# Serve a JSON API endpoint
|
# Serve a JSON API endpoint
|
||||||
def serve_api(self, data):
|
def serve_api(self, data):
|
||||||
self.last_api_access_time = datetime.now(pytz.UTC)
|
self.last_api_access_time = datetime.now(pytz.UTC)
|
||||||
@@ -142,7 +109,6 @@ class WebServer:
|
|||||||
|
|
||||||
response.content_type = 'application/json'
|
response.content_type = 'application/json'
|
||||||
response.set_header('Cache-Control', 'no-store')
|
response.set_header('Cache-Control', 'no-store')
|
||||||
response.status = 201
|
|
||||||
return json.dumps("OK", default=serialize_everything)
|
return json.dumps("OK", default=serialize_everything)
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
logging.error(e)
|
logging.error(e)
|
||||||
@@ -171,9 +137,6 @@ class WebServer:
|
|||||||
# in seconds UTC.
|
# in seconds UTC.
|
||||||
# We can also filter by source, sig, band, mode, dx_continent and de_continent. Each of these accepts a single
|
# We can also filter by source, sig, band, mode, dx_continent and de_continent. Each of these accepts a single
|
||||||
# value or a comma-separated list.
|
# value or a comma-separated list.
|
||||||
# We can filter by comments, accepting a single string, where the API will only return spots where the comment
|
|
||||||
# contains the provided value (case-insensitive).
|
|
||||||
# We can "de-dupe" spots, so only the latest spot will be sent for each callsign.
|
|
||||||
# We can provide a "limit" number as well. Spots are always returned newest-first; "limit" limits to only the
|
# We can provide a "limit" number as well. Spots are always returned newest-first; "limit" limits to only the
|
||||||
# most recent X spots.
|
# most recent X spots.
|
||||||
spot_ids = list(self.spots.iterkeys())
|
spot_ids = list(self.spots.iterkeys())
|
||||||
@@ -199,19 +162,8 @@ class WebServer:
|
|||||||
sources = query.get(k).split(",")
|
sources = query.get(k).split(",")
|
||||||
spots = [s for s in spots if s.source and s.source in sources]
|
spots = [s for s in spots if s.source and s.source in sources]
|
||||||
case "sig":
|
case "sig":
|
||||||
# If a list of sigs is provided, the spot must have a sig and it must match one of them
|
|
||||||
sigs = query.get(k).split(",")
|
sigs = query.get(k).split(",")
|
||||||
spots = [s for s in spots if s.sig and s.sig in sigs]
|
spots = [s for s in spots if s.sig and s.sig in sigs]
|
||||||
case "needs_sig":
|
|
||||||
# If true, a sig is required, regardless of what it is, it just can't be missing.
|
|
||||||
needs_sig = query.get(k).upper() == "TRUE"
|
|
||||||
if needs_sig:
|
|
||||||
spots = [s for s in spots if s.sig]
|
|
||||||
case "needs_sig_ref":
|
|
||||||
# If true, at least one sig ref is required, regardless of what it is, it just can't be missing.
|
|
||||||
needs_sig_ref = query.get(k).upper() == "TRUE"
|
|
||||||
if needs_sig_ref:
|
|
||||||
spots = [s for s in spots if s.sig_refs and len(s.sig_refs) > 0]
|
|
||||||
case "band":
|
case "band":
|
||||||
bands = query.get(k).split(",")
|
bands = query.get(k).split(",")
|
||||||
spots = [s for s in spots if s.band and s.band in bands]
|
spots = [s for s in spots if s.band and s.band in bands]
|
||||||
@@ -227,32 +179,6 @@ class WebServer:
|
|||||||
case "de_continent":
|
case "de_continent":
|
||||||
deconts = query.get(k).split(",")
|
deconts = query.get(k).split(",")
|
||||||
spots = [s for s in spots if s.de_continent and s.de_continent in deconts]
|
spots = [s for s in spots if s.de_continent and s.de_continent in deconts]
|
||||||
case "comment_includes":
|
|
||||||
comment_includes = query.get(k).strip()
|
|
||||||
spots = [s for s in spots if s.comment and comment_includes.upper() in s.comment.upper()]
|
|
||||||
case "allow_qrt":
|
|
||||||
# If false, spots that are flagged as QRT are not returned.
|
|
||||||
prevent_qrt = query.get(k).upper() == "FALSE"
|
|
||||||
if prevent_qrt:
|
|
||||||
spots = [s for s in spots if not s.qrt or s.qrt == False]
|
|
||||||
case "needs_good_location":
|
|
||||||
# If true, spots require a "good" location to be returned
|
|
||||||
needs_good_location = query.get(k).upper() == "TRUE"
|
|
||||||
if needs_good_location:
|
|
||||||
spots = [s for s in spots if s.dx_location_good]
|
|
||||||
case "dedupe":
|
|
||||||
# Ensure only the latest spot of each callsign is present in the list. This relies on the list being
|
|
||||||
# in reverse time order, so if any future change allows re-ordering the list, that should be done
|
|
||||||
# *after* this.
|
|
||||||
dedupe = query.get(k).upper() == "TRUE"
|
|
||||||
if dedupe:
|
|
||||||
spots_temp = []
|
|
||||||
already_seen = []
|
|
||||||
for s in spots:
|
|
||||||
if s.dx_call not in already_seen:
|
|
||||||
spots_temp.append(s)
|
|
||||||
already_seen.append(s.dx_call)
|
|
||||||
spots = spots_temp
|
|
||||||
# If we have a "limit" parameter, we apply that last, regardless of where it appeared in the list of keys.
|
# If we have a "limit" parameter, we apply that last, regardless of where it appeared in the list of keys.
|
||||||
if "limit" in query.keys():
|
if "limit" in query.keys():
|
||||||
spots = spots[:int(query.get("limit"))]
|
spots = spots[:int(query.get("limit"))]
|
||||||
|
|||||||
@@ -7,8 +7,6 @@ from time import sleep
|
|||||||
import pytz
|
import pytz
|
||||||
import telnetlib3
|
import telnetlib3
|
||||||
|
|
||||||
from core.constants import SIGS
|
|
||||||
from core.sig_utils import ANY_SIG_REGEX, ANY_XOTA_SIG_REF_REGEX, get_icon_for_sig, get_ref_regex_for_sig
|
|
||||||
from data.spot import Spot
|
from data.spot import Spot
|
||||||
from core.config import SERVER_OWNER_CALLSIGN
|
from core.config import SERVER_OWNER_CALLSIGN
|
||||||
from spotproviders.spot_provider import SpotProvider
|
from spotproviders.spot_provider import SpotProvider
|
||||||
@@ -19,9 +17,9 @@ class DXCluster(SpotProvider):
|
|||||||
# Note the callsign pattern deliberately excludes calls ending in "-#", which are from RBN and can be enabled by
|
# Note the callsign pattern deliberately excludes calls ending in "-#", which are from RBN and can be enabled by
|
||||||
# default on some clusters. If you want RBN spots, there is a separate provider for that.
|
# default on some clusters. If you want RBN spots, there is a separate provider for that.
|
||||||
CALLSIGN_PATTERN = "([a-z|0-9|/]+)"
|
CALLSIGN_PATTERN = "([a-z|0-9|/]+)"
|
||||||
FREQUENCY_PATTERN = "([0-9|.]+)"
|
FREQUENCY_PATTERM = "([0-9|.]+)"
|
||||||
LINE_PATTERN = re.compile(
|
LINE_PATTERN = re.compile(
|
||||||
"^DX de " + CALLSIGN_PATTERN + ":\\s+" + FREQUENCY_PATTERN + "\\s+" + CALLSIGN_PATTERN + "\\s+(.*)\\s+(\\d{4}Z)",
|
"^DX de " + CALLSIGN_PATTERN + ":\\s+" + FREQUENCY_PATTERM + "\\s+" + CALLSIGN_PATTERN + "\\s+(.*)\\s+(\\d{4}Z)",
|
||||||
re.IGNORECASE)
|
re.IGNORECASE)
|
||||||
|
|
||||||
# Constructor requires hostname and port
|
# Constructor requires hostname and port
|
||||||
@@ -77,21 +75,6 @@ class DXCluster(SpotProvider):
|
|||||||
icon="desktop",
|
icon="desktop",
|
||||||
time=spot_datetime.timestamp())
|
time=spot_datetime.timestamp())
|
||||||
|
|
||||||
# See if the comment looks like it contains a SIG (and optionally SIG reference). Currently,
|
|
||||||
# only one sig ref is supported. Note that this code is specifically in the DX Cluster class and
|
|
||||||
# not in the general "spot" infer_missing() method. Because we only support one SIG per spot
|
|
||||||
# at the moment (see issue #54), we don't want to risk e.g. a POTA spot with comment "WWFF GFF-0001"
|
|
||||||
# being converted into a WWFF spot.
|
|
||||||
sig_match = re.search(r"(^|\W)" + ANY_SIG_REGEX + r"($|\W)", spot.comment, re.IGNORECASE)
|
|
||||||
if sig_match:
|
|
||||||
spot.sig = sig_match.group(2).upper()
|
|
||||||
spot.icon = get_icon_for_sig(spot.sig)
|
|
||||||
ref_regex = get_ref_regex_for_sig(spot.sig)
|
|
||||||
if ref_regex:
|
|
||||||
sig_ref_match = re.search(r"(^|\W)" + spot.sig + r"($|\W)(" + ref_regex + r")($|\W)", spot.comment, re.IGNORECASE)
|
|
||||||
if sig_ref_match:
|
|
||||||
spot.sig_refs = [sig_ref_match.group(3).upper()]
|
|
||||||
|
|
||||||
# Add to our list
|
# Add to our list
|
||||||
self.submit(spot)
|
self.submit(spot)
|
||||||
|
|
||||||
|
|||||||
@@ -5,7 +5,6 @@ import pytz
|
|||||||
from requests_cache import CachedSession
|
from requests_cache import CachedSession
|
||||||
|
|
||||||
from core.constants import HTTP_HEADERS
|
from core.constants import HTTP_HEADERS
|
||||||
from core.sig_utils import get_icon_for_sig
|
|
||||||
from data.spot import Spot
|
from data.spot import Spot
|
||||||
from spotproviders.http_spot_provider import HTTPSpotProvider
|
from spotproviders.http_spot_provider import HTTPSpotProvider
|
||||||
|
|
||||||
@@ -37,7 +36,6 @@ class GMA(HTTPSpotProvider):
|
|||||||
comment=source_spot["TEXT"],
|
comment=source_spot["TEXT"],
|
||||||
sig_refs=[source_spot["REF"]],
|
sig_refs=[source_spot["REF"]],
|
||||||
sig_refs_names=[source_spot["NAME"]],
|
sig_refs_names=[source_spot["NAME"]],
|
||||||
sig_refs_urls=["https://www.cqgma.org/zinfo.php?ref=" + source_spot["REF"]],
|
|
||||||
time=datetime.strptime(source_spot["DATE"] + source_spot["TIME"], "%Y%m%d%H%M").replace(
|
time=datetime.strptime(source_spot["DATE"] + source_spot["TIME"], "%Y%m%d%H%M").replace(
|
||||||
tzinfo=pytz.UTC).timestamp(),
|
tzinfo=pytz.UTC).timestamp(),
|
||||||
dx_latitude=float(source_spot["LAT"]) if (source_spot["LAT"] and source_spot["LAT"] != "") else None,
|
dx_latitude=float(source_spot["LAT"]) if (source_spot["LAT"] and source_spot["LAT"] != "") else None,
|
||||||
@@ -58,21 +56,27 @@ class GMA(HTTPSpotProvider):
|
|||||||
match ref_info["reftype"]:
|
match ref_info["reftype"]:
|
||||||
case "Summit":
|
case "Summit":
|
||||||
spot.sig = "GMA"
|
spot.sig = "GMA"
|
||||||
|
spot.icon = "mountain"
|
||||||
case "IOTA Island":
|
case "IOTA Island":
|
||||||
spot.sig = "IOTA"
|
spot.sig = "IOTA"
|
||||||
|
spot.icon = "umbrella-beach"
|
||||||
case "Lighthouse (ILLW)":
|
case "Lighthouse (ILLW)":
|
||||||
spot.sig = "ILLW"
|
spot.sig = "ILLW"
|
||||||
|
spot.icon = "tower-observation"
|
||||||
case "Lighthouse (ARLHS)":
|
case "Lighthouse (ARLHS)":
|
||||||
spot.sig = "ARLHS"
|
spot.sig = "ARLHS"
|
||||||
|
spot.icon = "tower-observation"
|
||||||
case "Castle":
|
case "Castle":
|
||||||
spot.sig = "WCA"
|
spot.sig = "WCA/COTA"
|
||||||
|
spot.icon = "chess-rook"
|
||||||
case "Mill":
|
case "Mill":
|
||||||
spot.sig = "MOTA"
|
spot.sig = "MOTA"
|
||||||
|
spot.icon = "fan"
|
||||||
case _:
|
case _:
|
||||||
logging.warn("GMA spot found with ref type " + ref_info[
|
logging.warn("GMA spot found with ref type " + ref_info[
|
||||||
"reftype"] + ", developer needs to add support for this!")
|
"reftype"] + ", developer needs to figure out an icon for this!")
|
||||||
spot.sig = ref_info["reftype"]
|
spot.sig = ref_info["reftype"]
|
||||||
spot.icon = get_icon_for_sig(spot.sig)
|
spot.icon = "person-hiking"
|
||||||
|
|
||||||
# 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.
|
||||||
|
|||||||
@@ -5,7 +5,6 @@ import pytz
|
|||||||
import requests
|
import requests
|
||||||
|
|
||||||
from core.constants import HTTP_HEADERS
|
from core.constants import HTTP_HEADERS
|
||||||
from core.sig_utils import get_icon_for_sig
|
|
||||||
from data.spot import Spot
|
from data.spot import Spot
|
||||||
from spotproviders.http_spot_provider import HTTPSpotProvider
|
from spotproviders.http_spot_provider import HTTPSpotProvider
|
||||||
|
|
||||||
@@ -55,7 +54,7 @@ class HEMA(HTTPSpotProvider):
|
|||||||
sig="HEMA",
|
sig="HEMA",
|
||||||
sig_refs=[spot_items[3].upper()],
|
sig_refs=[spot_items[3].upper()],
|
||||||
sig_refs_names=[spot_items[4]],
|
sig_refs_names=[spot_items[4]],
|
||||||
icon=get_icon_for_sig("HEMA"),
|
icon="mound",
|
||||||
time=datetime.strptime(spot_items[0], "%d/%m/%Y %H:%M").replace(tzinfo=pytz.UTC).timestamp(),
|
time=datetime.strptime(spot_items[0], "%d/%m/%Y %H:%M").replace(tzinfo=pytz.UTC).timestamp(),
|
||||||
dx_latitude=float(spot_items[7]),
|
dx_latitude=float(spot_items[7]),
|
||||||
dx_longitude=float(spot_items[8]))
|
dx_longitude=float(spot_items[8]))
|
||||||
|
|||||||
@@ -1,13 +1,11 @@
|
|||||||
import csv
|
import csv
|
||||||
import logging
|
import logging
|
||||||
import re
|
|
||||||
from datetime import datetime, timedelta
|
from datetime import datetime, timedelta
|
||||||
|
|
||||||
import pytz
|
import pytz
|
||||||
from requests_cache import CachedSession
|
from requests_cache import CachedSession
|
||||||
|
|
||||||
from core.constants import HTTP_HEADERS
|
from core.constants import HTTP_HEADERS
|
||||||
from core.sig_utils import get_icon_for_sig
|
|
||||||
from data.spot import Spot
|
from data.spot import Spot
|
||||||
from spotproviders.http_spot_provider import HTTPSpotProvider
|
from spotproviders.http_spot_provider import HTTPSpotProvider
|
||||||
|
|
||||||
@@ -19,6 +17,9 @@ class ParksNPeaks(HTTPSpotProvider):
|
|||||||
SIOTA_LIST_URL = "https://www.silosontheair.com/data/silos.csv"
|
SIOTA_LIST_URL = "https://www.silosontheair.com/data/silos.csv"
|
||||||
SIOTA_LIST_CACHE_TIME_DAYS = 30
|
SIOTA_LIST_CACHE_TIME_DAYS = 30
|
||||||
SIOTA_LIST_CACHE = CachedSession("cache/siota_data_cache", expire_after=timedelta(days=SIOTA_LIST_CACHE_TIME_DAYS))
|
SIOTA_LIST_CACHE = CachedSession("cache/siota_data_cache", expire_after=timedelta(days=SIOTA_LIST_CACHE_TIME_DAYS))
|
||||||
|
ZLOTA_LIST_URL = "https://ontheair.nz/assets/assets.json"
|
||||||
|
ZLOTA_LIST_CACHE_TIME_DAYS = 30
|
||||||
|
ZLOTA_LIST_CACHE = CachedSession("cache/zlota_data_cache", expire_after=timedelta(days=ZLOTA_LIST_CACHE_TIME_DAYS))
|
||||||
|
|
||||||
def __init__(self, provider_config):
|
def __init__(self, provider_config):
|
||||||
super().__init__(provider_config, self.SPOTS_URL, self.POLL_INTERVAL_SEC)
|
super().__init__(provider_config, self.SPOTS_URL, self.POLL_INTERVAL_SEC)
|
||||||
@@ -31,7 +32,7 @@ class ParksNPeaks(HTTPSpotProvider):
|
|||||||
spot = Spot(source=self.name,
|
spot = Spot(source=self.name,
|
||||||
source_id=source_spot["actID"],
|
source_id=source_spot["actID"],
|
||||||
dx_call=source_spot["actCallsign"].upper(),
|
dx_call=source_spot["actCallsign"].upper(),
|
||||||
de_call=source_spot["actSpoter"].upper() if source_spot["actSpoter"] != "" else None, # typo exists in API
|
de_call=source_spot["actSpoter"].upper(), # typo exists in API
|
||||||
freq=float(source_spot["actFreq"].replace(",", "")) * 1000000 if (
|
freq=float(source_spot["actFreq"].replace(",", "")) * 1000000 if (
|
||||||
source_spot["actFreq"] != "") else None,
|
source_spot["actFreq"] != "") else None,
|
||||||
# Seen PNP spots with empty frequency, and with comma-separated thousands digits
|
# Seen PNP spots with empty frequency, and with comma-separated thousands digits
|
||||||
@@ -39,22 +40,22 @@ class ParksNPeaks(HTTPSpotProvider):
|
|||||||
comment=source_spot["actComments"],
|
comment=source_spot["actComments"],
|
||||||
sig=source_spot["actClass"],
|
sig=source_spot["actClass"],
|
||||||
sig_refs=[source_spot["actSiteID"]],
|
sig_refs=[source_spot["actSiteID"]],
|
||||||
icon=get_icon_for_sig(source_spot["actClass"]),
|
|
||||||
time=datetime.strptime(source_spot["actTime"], "%Y-%m-%d %H:%M:%S").replace(
|
time=datetime.strptime(source_spot["actTime"], "%Y-%m-%d %H:%M:%S").replace(
|
||||||
tzinfo=pytz.UTC).timestamp())
|
tzinfo=pytz.UTC).timestamp())
|
||||||
|
|
||||||
# Free text location is not present in all spots, so only add it if it's set
|
# PNP supports a bunch of programs which should have different icons
|
||||||
if "actLocation" in source_spot and source_spot["actLocation"] != "":
|
if spot.sig == "SiOTA":
|
||||||
spot.sig_refs_names = [source_spot["actLocation"]]
|
spot.icon = "wheat-awn"
|
||||||
|
elif spot.sig == "ZLOTA":
|
||||||
# Extract a de_call if it's in the comment but not in the "actSpoter" field
|
spot.icon = "kiwi-bird"
|
||||||
m = re.search(r"\(de ([A-Za-z0-9]*)\)", spot.comment)
|
elif spot.sig in ["POTA", "SOTA", "WWFF"]:
|
||||||
if not spot.de_call and m:
|
# Don't care about an icon as this will be rejected anyway, we have better data from POTA/SOTA/WWFF direct
|
||||||
spot.de_call = m.group(1)
|
spot.icon = ""
|
||||||
|
else:
|
||||||
# Log a warning for the developer if PnP gives us an unknown programme we've never seen before
|
# Unknown programme we've never seen before
|
||||||
if spot.sig not in ["POTA", "SOTA", "WWFF", "SiOTA", "ZLOTA", "KRMNPA"]:
|
logging.warn(
|
||||||
logging.warn("PNP spot found with sig " + spot.sig + ", developer needs to add support for this!")
|
"PNP spot found with sig " + spot.sig + ", developer needs to add support for icon and grid/lat/lon lookup!")
|
||||||
|
spot.icon = "question"
|
||||||
|
|
||||||
# SiOTA lat/lon/grid lookup
|
# SiOTA lat/lon/grid lookup
|
||||||
if spot.sig == "SiOTA":
|
if spot.sig == "SiOTA":
|
||||||
@@ -67,10 +68,20 @@ class ParksNPeaks(HTTPSpotProvider):
|
|||||||
spot.dx_grid = row["LOCATOR"]
|
spot.dx_grid = row["LOCATOR"]
|
||||||
break
|
break
|
||||||
|
|
||||||
# Note there is currently no support for KRMNPA location lookup, see issue #61.
|
# ZLOTA name/lat/lon lookup
|
||||||
|
if spot.sig == "ZLOTA":
|
||||||
|
zlota_data = self.ZLOTA_LIST_CACHE.get(self.ZLOTA_LIST_URL, headers=HTTP_HEADERS).json()
|
||||||
|
for asset in zlota_data:
|
||||||
|
if asset["code"] == spot.sig_refs[0]:
|
||||||
|
spot.sig_refs_names = [asset["name"]]
|
||||||
|
spot.dx_latitude = asset["y"]
|
||||||
|
spot.dx_longitude = asset["x"]
|
||||||
|
# Junk the "DE call", PNP always returns "ZLOTA" as the spotter for ZLOTA spots
|
||||||
|
spot.de_call = None
|
||||||
|
break
|
||||||
|
|
||||||
# If this is POTA, SOTA, WWFF or ZLOTA data we already have it through other means, so ignore. Otherwise,
|
# If this is POTA, SOTA or WWFF data we already have it through other means, so ignore. Otherwise, add to
|
||||||
# add to the spot list.
|
# the spot list.
|
||||||
if spot.sig not in ["POTA", "SOTA", "WWFF", "ZLOTA"]:
|
if spot.sig not in ["POTA", "SOTA", "WWFF"]:
|
||||||
new_spots.append(spot)
|
new_spots.append(spot)
|
||||||
return new_spots
|
return new_spots
|
||||||
|
|||||||
@@ -1,11 +1,7 @@
|
|||||||
import re
|
from datetime import datetime
|
||||||
from datetime import datetime, timedelta
|
|
||||||
|
|
||||||
import pytz
|
import pytz
|
||||||
from requests_cache import CachedSession
|
|
||||||
|
|
||||||
from core.constants import HTTP_HEADERS
|
|
||||||
from core.sig_utils import get_icon_for_sig, get_ref_regex_for_sig
|
|
||||||
from data.spot import Spot
|
from data.spot import Spot
|
||||||
from spotproviders.http_spot_provider import HTTPSpotProvider
|
from spotproviders.http_spot_provider import HTTPSpotProvider
|
||||||
|
|
||||||
@@ -14,11 +10,6 @@ from spotproviders.http_spot_provider import HTTPSpotProvider
|
|||||||
class POTA(HTTPSpotProvider):
|
class POTA(HTTPSpotProvider):
|
||||||
POLL_INTERVAL_SEC = 120
|
POLL_INTERVAL_SEC = 120
|
||||||
SPOTS_URL = "https://api.pota.app/spot/activator"
|
SPOTS_URL = "https://api.pota.app/spot/activator"
|
||||||
# Might need to look up extra park data
|
|
||||||
PARK_URL_ROOT = "https://api.pota.app/park/"
|
|
||||||
PARK_DATA_CACHE_TIME_DAYS = 30
|
|
||||||
PARK_DATA_CACHE = CachedSession("cache/pota_park_data_cache",
|
|
||||||
expire_after=timedelta(days=PARK_DATA_CACHE_TIME_DAYS))
|
|
||||||
|
|
||||||
def __init__(self, provider_config):
|
def __init__(self, provider_config):
|
||||||
super().__init__(provider_config, self.SPOTS_URL, self.POLL_INTERVAL_SEC)
|
super().__init__(provider_config, self.SPOTS_URL, self.POLL_INTERVAL_SEC)
|
||||||
@@ -38,28 +29,13 @@ class POTA(HTTPSpotProvider):
|
|||||||
sig="POTA",
|
sig="POTA",
|
||||||
sig_refs=[source_spot["reference"]],
|
sig_refs=[source_spot["reference"]],
|
||||||
sig_refs_names=[source_spot["name"]],
|
sig_refs_names=[source_spot["name"]],
|
||||||
sig_refs_urls=["https://pota.app/#/park/" + source_spot["reference"]],
|
icon="tree",
|
||||||
icon=get_icon_for_sig("POTA"),
|
time=datetime.strptime(source_spot["spotTime"], "%Y-%m-%dT%H:%M:%S").replace(tzinfo=pytz.UTC).timestamp(),
|
||||||
time=datetime.strptime(source_spot["spotTime"], "%Y-%m-%dT%H:%M:%S").replace(
|
|
||||||
tzinfo=pytz.UTC).timestamp(),
|
|
||||||
dx_grid=source_spot["grid6"],
|
dx_grid=source_spot["grid6"],
|
||||||
dx_latitude=source_spot["latitude"],
|
dx_latitude=source_spot["latitude"],
|
||||||
dx_longitude=source_spot["longitude"])
|
dx_longitude=source_spot["longitude"])
|
||||||
|
|
||||||
# Sometimes we can get other refs in the comments for n-fer activations, extract them
|
|
||||||
all_comment_refs = re.findall(get_ref_regex_for_sig("POTA"), spot.comment)
|
|
||||||
for r in all_comment_refs:
|
|
||||||
if r not in spot.sig_refs:
|
|
||||||
spot.sig_refs.append(r.upper())
|
|
||||||
spot.sig_refs_urls.append("https://pota.app/#/park/" + r.upper())
|
|
||||||
|
|
||||||
# Now we need to look up the name of that reference from the API, because the comment won't have it
|
|
||||||
park_response = self.PARK_DATA_CACHE.get(self.PARK_URL_ROOT + r.upper(), headers=HTTP_HEADERS)
|
|
||||||
park_data = park_response.json()
|
|
||||||
if park_data and "name" in park_data:
|
|
||||||
spot.sig_refs_names.append(park_data["name"])
|
|
||||||
|
|
||||||
# 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.
|
||||||
new_spots.append(spot)
|
new_spots.append(spot)
|
||||||
return new_spots
|
return new_spots
|
||||||
@@ -5,7 +5,6 @@ import requests
|
|||||||
from requests_cache import CachedSession
|
from requests_cache import CachedSession
|
||||||
|
|
||||||
from core.constants import HTTP_HEADERS
|
from core.constants import HTTP_HEADERS
|
||||||
from core.sig_utils import get_icon_for_sig
|
|
||||||
from data.spot import Spot
|
from data.spot import Spot
|
||||||
from spotproviders.http_spot_provider import HTTPSpotProvider
|
from spotproviders.http_spot_provider import HTTPSpotProvider
|
||||||
|
|
||||||
@@ -51,8 +50,7 @@ class SOTA(HTTPSpotProvider):
|
|||||||
sig="SOTA",
|
sig="SOTA",
|
||||||
sig_refs=[source_spot["summitCode"]],
|
sig_refs=[source_spot["summitCode"]],
|
||||||
sig_refs_names=[source_spot["summitName"]],
|
sig_refs_names=[source_spot["summitName"]],
|
||||||
sig_refs_urls=["https://www.sotadata.org.uk/en/summit/" + source_spot["summitCode"]],
|
icon="mountain-sun",
|
||||||
icon=get_icon_for_sig("SOTA"),
|
|
||||||
time=datetime.fromisoformat(source_spot["timeStamp"]).timestamp(),
|
time=datetime.fromisoformat(source_spot["timeStamp"]).timestamp(),
|
||||||
activation_score=source_spot["points"])
|
activation_score=source_spot["points"])
|
||||||
|
|
||||||
|
|||||||
@@ -34,19 +34,11 @@ class SSESpotProvider(SpotProvider):
|
|||||||
if self.thread:
|
if self.thread:
|
||||||
self.thread.join()
|
self.thread.join()
|
||||||
|
|
||||||
def _on_open(self):
|
|
||||||
self.status = "Waiting for Data"
|
|
||||||
|
|
||||||
def _on_error(self):
|
|
||||||
self.status = "Connecting"
|
|
||||||
|
|
||||||
def run(self):
|
def run(self):
|
||||||
while not self.stopped:
|
while not self.stopped:
|
||||||
try:
|
try:
|
||||||
logging.debug("Connecting to " + self.name + " spot API...")
|
logging.debug("Connecting to " + self.name + " spot API...")
|
||||||
self.status = "Connecting"
|
with EventSource(self.url, headers=HTTP_HEADERS, latest_event_id=self.last_event_id, timeout=30) as event_source:
|
||||||
with EventSource(self.url, headers=HTTP_HEADERS, latest_event_id=self.last_event_id, timeout=30,
|
|
||||||
on_open=self._on_open, on_error=self._on_error) as event_source:
|
|
||||||
self.event_source = event_source
|
self.event_source = event_source
|
||||||
for event in self.event_source:
|
for event in self.event_source:
|
||||||
if event.type == 'message':
|
if event.type == 'message':
|
||||||
@@ -66,8 +58,6 @@ class SSESpotProvider(SpotProvider):
|
|||||||
except Exception as e:
|
except Exception as e:
|
||||||
self.status = "Error"
|
self.status = "Error"
|
||||||
logging.exception("Exception in SSE Spot Provider (" + self.name + ")")
|
logging.exception("Exception in SSE Spot Provider (" + self.name + ")")
|
||||||
else:
|
|
||||||
self.status = "Disconnected"
|
|
||||||
sleep(5) # Wait before trying to reconnect
|
sleep(5) # Wait before trying to reconnect
|
||||||
|
|
||||||
# Convert an SSE message received from the API into a spot. The whole message data is provided here so the subclass
|
# Convert an SSE message received from the API into a spot. The whole message data is provided here so the subclass
|
||||||
|
|||||||
@@ -1,88 +0,0 @@
|
|||||||
from datetime import timedelta, datetime
|
|
||||||
|
|
||||||
import pytz
|
|
||||||
from requests_cache import CachedSession
|
|
||||||
from rss_parser import RSSParser
|
|
||||||
|
|
||||||
from core.constants import HTTP_HEADERS
|
|
||||||
from core.sig_utils import get_icon_for_sig
|
|
||||||
from data.spot import Spot
|
|
||||||
from spotproviders.http_spot_provider import HTTPSpotProvider
|
|
||||||
|
|
||||||
|
|
||||||
# Spot provider for Wainwrights on the Air
|
|
||||||
class WOTA(HTTPSpotProvider):
|
|
||||||
POLL_INTERVAL_SEC = 120
|
|
||||||
SPOTS_URL = "https://www.wota.org.uk/spots_rss.php"
|
|
||||||
LIST_URL = "https://www.wota.org.uk/mapping/data/summits.json"
|
|
||||||
LIST_CACHE_TIME_DAYS = 30
|
|
||||||
LIST_CACHE = CachedSession("cache/wota_data_cache", expire_after=timedelta(days=LIST_CACHE_TIME_DAYS))
|
|
||||||
RSS_DATE_TIME_FORMAT = "%a, %d %b %Y %H:%M:%S %z"
|
|
||||||
|
|
||||||
def __init__(self, provider_config):
|
|
||||||
super().__init__(provider_config, self.SPOTS_URL, self.POLL_INTERVAL_SEC)
|
|
||||||
|
|
||||||
def http_response_to_spots(self, http_response):
|
|
||||||
new_spots = []
|
|
||||||
rss = RSSParser.parse(http_response.content.decode())
|
|
||||||
# Iterate through source data
|
|
||||||
for source_spot in rss.channel.items:
|
|
||||||
|
|
||||||
# Reject GUID missing or zero
|
|
||||||
if not source_spot.guid or not source_spot.guid.content or source_spot.guid.content == "http://www.wota.org.uk/spots/0":
|
|
||||||
continue
|
|
||||||
|
|
||||||
# Pick apart the title
|
|
||||||
title_split = source_spot.title.split(" on ")
|
|
||||||
dx_call = title_split[0]
|
|
||||||
ref = None
|
|
||||||
ref_name = None
|
|
||||||
if len(title_split) > 1:
|
|
||||||
ref_split = title_split[1].split(" - ")
|
|
||||||
ref = ref_split[0]
|
|
||||||
if len(ref_split) > 1:
|
|
||||||
ref_name = ref_split[1]
|
|
||||||
|
|
||||||
# Pick apart the description
|
|
||||||
desc_split = source_spot.description.split(". ")
|
|
||||||
freq_mode = desc_split[0].replace("Frequencies/modes:", "").strip()
|
|
||||||
freq_mode_split = freq_mode.split("-")
|
|
||||||
freq_hz = float(freq_mode_split[0]) * 1000000
|
|
||||||
mode = freq_mode_split[1]
|
|
||||||
|
|
||||||
comment = None
|
|
||||||
if len(desc_split) > 1:
|
|
||||||
comment = desc_split[1].strip()
|
|
||||||
spotter = None
|
|
||||||
if len(desc_split) > 2:
|
|
||||||
spotter = desc_split[2].replace("Spotted by ", "").replace(".", "").strip()
|
|
||||||
|
|
||||||
time = datetime.strptime(source_spot.pub_date.content, self.RSS_DATE_TIME_FORMAT).astimezone(pytz.UTC)
|
|
||||||
|
|
||||||
# Convert to our spot format
|
|
||||||
spot = Spot(source=self.name,
|
|
||||||
source_id=source_spot.guid.content,
|
|
||||||
dx_call=dx_call,
|
|
||||||
de_call=spotter,
|
|
||||||
freq=freq_hz,
|
|
||||||
mode=mode,
|
|
||||||
comment=comment,
|
|
||||||
sig="WOTA",
|
|
||||||
sig_refs=[ref] if ref else [],
|
|
||||||
sig_refs_names=[ref_name] if ref_name else [],
|
|
||||||
sig_refs_urls="https://www.wota.org.uk/MM_" + ref if ref else [],
|
|
||||||
icon=get_icon_for_sig("WOTA"),
|
|
||||||
time=time.timestamp())
|
|
||||||
|
|
||||||
# WOTA name/lat/lon lookup
|
|
||||||
wota_data = self.LIST_CACHE.get(self.LIST_URL, headers=HTTP_HEADERS).json()
|
|
||||||
for feature in wota_data["features"]:
|
|
||||||
if feature["properties"]["wotaId"] == spot.sig_refs[0]:
|
|
||||||
spot.sig_refs_names = [feature["properties"]["title"]]
|
|
||||||
spot.dx_latitude = feature["geometry"]["coordinates"][1]
|
|
||||||
spot.dx_longitude = feature["geometry"]["coordinates"][0]
|
|
||||||
spot.dx_grid = feature["properties"]["qthLocator"]
|
|
||||||
break
|
|
||||||
|
|
||||||
new_spots.append(spot)
|
|
||||||
return new_spots
|
|
||||||
@@ -1,7 +1,6 @@
|
|||||||
import json
|
import json
|
||||||
from datetime import datetime
|
from datetime import datetime
|
||||||
|
|
||||||
from core.sig_utils import get_icon_for_sig
|
|
||||||
from data.spot import Spot
|
from data.spot import Spot
|
||||||
from spotproviders.sse_spot_provider import SSESpotProvider
|
from spotproviders.sse_spot_provider import SSESpotProvider
|
||||||
|
|
||||||
@@ -19,16 +18,9 @@ class WWBOTA(SSESpotProvider):
|
|||||||
# n-fer activations.
|
# n-fer activations.
|
||||||
refs = []
|
refs = []
|
||||||
ref_names = []
|
ref_names = []
|
||||||
ref_urls = []
|
|
||||||
for ref in source_spot["references"]:
|
for ref in source_spot["references"]:
|
||||||
refs.append(ref["reference"])
|
refs.append(ref["reference"])
|
||||||
ref_names.append(ref["name"])
|
ref_names.append(ref["name"])
|
||||||
# Bunkerbase URLs only work for UK bunkers, so only add a URL if we have a B/G prefix. In theory this could
|
|
||||||
# lead to array alignment mismatches if there was e.g. a B/F bunker followed by a B/G one, we'd end up with
|
|
||||||
# the B/G URL in index 0. But in practice there are no overlaps between B/G bunkers and any others, so an
|
|
||||||
# activation will either be entirely B/G or not B/G at all.
|
|
||||||
if ref["reference"].startswith("B/G"):
|
|
||||||
ref_urls.append("https://bunkerwiki.org/?s=" + ref["reference"])
|
|
||||||
|
|
||||||
spot = Spot(source=self.name,
|
spot = Spot(source=self.name,
|
||||||
dx_call=source_spot["call"].upper(),
|
dx_call=source_spot["call"].upper(),
|
||||||
@@ -39,7 +31,7 @@ class WWBOTA(SSESpotProvider):
|
|||||||
sig="WWBOTA",
|
sig="WWBOTA",
|
||||||
sig_refs=refs,
|
sig_refs=refs,
|
||||||
sig_refs_names=ref_names,
|
sig_refs_names=ref_names,
|
||||||
icon=get_icon_for_sig("WWBOTA"),
|
icon="radiation",
|
||||||
time=datetime.fromisoformat(source_spot["time"]).timestamp(),
|
time=datetime.fromisoformat(source_spot["time"]).timestamp(),
|
||||||
# WWBOTA spots can contain multiple references for bunkers being activated simultaneously. For
|
# WWBOTA spots can contain multiple references for bunkers being activated simultaneously. For
|
||||||
# now, we will just pick the first one to use as our grid, latitude and longitude.
|
# now, we will just pick the first one to use as our grid, latitude and longitude.
|
||||||
|
|||||||
@@ -2,7 +2,6 @@ from datetime import datetime
|
|||||||
|
|
||||||
import pytz
|
import pytz
|
||||||
|
|
||||||
from core.sig_utils import get_icon_for_sig
|
|
||||||
from data.spot import Spot
|
from data.spot import Spot
|
||||||
from spotproviders.http_spot_provider import HTTPSpotProvider
|
from spotproviders.http_spot_provider import HTTPSpotProvider
|
||||||
|
|
||||||
@@ -30,8 +29,7 @@ class WWFF(HTTPSpotProvider):
|
|||||||
sig="WWFF",
|
sig="WWFF",
|
||||||
sig_refs=[source_spot["reference"]],
|
sig_refs=[source_spot["reference"]],
|
||||||
sig_refs_names=[source_spot["reference_name"]],
|
sig_refs_names=[source_spot["reference_name"]],
|
||||||
sig_refs_urls=["https://wwff.co/directory/?showRef=" + source_spot["reference"]],
|
icon="seedling",
|
||||||
icon=get_icon_for_sig("WWFF"),
|
|
||||||
time=datetime.fromtimestamp(source_spot["spot_time"], tz=pytz.UTC).timestamp(),
|
time=datetime.fromtimestamp(source_spot["spot_time"], tz=pytz.UTC).timestamp(),
|
||||||
dx_latitude=source_spot["latitude"],
|
dx_latitude=source_spot["latitude"],
|
||||||
dx_longitude=source_spot["longitude"])
|
dx_longitude=source_spot["longitude"])
|
||||||
|
|||||||
@@ -1,59 +0,0 @@
|
|||||||
import csv
|
|
||||||
import logging
|
|
||||||
import re
|
|
||||||
from datetime import datetime, timedelta
|
|
||||||
|
|
||||||
import pytz
|
|
||||||
from requests_cache import CachedSession
|
|
||||||
|
|
||||||
from core.constants import HTTP_HEADERS
|
|
||||||
from core.sig_utils import get_icon_for_sig
|
|
||||||
from data.spot import Spot
|
|
||||||
from spotproviders.http_spot_provider import HTTPSpotProvider
|
|
||||||
|
|
||||||
|
|
||||||
# Spot provider for ZLOTA
|
|
||||||
class ZLOTA(HTTPSpotProvider):
|
|
||||||
POLL_INTERVAL_SEC = 120
|
|
||||||
SPOTS_URL = "https://ontheair.nz/api/spots?zlota_only=true"
|
|
||||||
LIST_URL = "https://ontheair.nz/assets/assets.json"
|
|
||||||
LIST_CACHE_TIME_DAYS = 30
|
|
||||||
LIST_CACHE = CachedSession("cache/zlota_data_cache", expire_after=timedelta(days=LIST_CACHE_TIME_DAYS))
|
|
||||||
|
|
||||||
def __init__(self, provider_config):
|
|
||||||
super().__init__(provider_config, self.SPOTS_URL, self.POLL_INTERVAL_SEC)
|
|
||||||
|
|
||||||
def http_response_to_spots(self, http_response):
|
|
||||||
new_spots = []
|
|
||||||
# Iterate through source data
|
|
||||||
for source_spot in http_response.json():
|
|
||||||
# Frequency is often inconsistent as to whether it's in Hz or kHz. Make a guess.
|
|
||||||
freq_hz = float(source_spot["frequency"])
|
|
||||||
if freq_hz < 1000000:
|
|
||||||
freq_hz = freq_hz * 1000
|
|
||||||
|
|
||||||
# Convert to our spot format
|
|
||||||
spot = Spot(source=self.name,
|
|
||||||
source_id=source_spot["id"],
|
|
||||||
dx_call=source_spot["activator"].upper(),
|
|
||||||
de_call=source_spot["spotter"].upper(),
|
|
||||||
freq=freq_hz,
|
|
||||||
mode=source_spot["mode"].upper().strip(),
|
|
||||||
comment=source_spot["comments"],
|
|
||||||
sig="ZLOTA",
|
|
||||||
sig_refs=[source_spot["reference"]],
|
|
||||||
sig_refs_names=[source_spot["name"]],
|
|
||||||
icon=get_icon_for_sig("ZLOTA"),
|
|
||||||
time=datetime.fromisoformat(source_spot["referenced_time"]).astimezone(pytz.UTC).timestamp())
|
|
||||||
|
|
||||||
# ZLOTA name/lat/lon lookup
|
|
||||||
zlota_data = self.LIST_CACHE.get(self.LIST_URL, headers=HTTP_HEADERS).json()
|
|
||||||
for asset in zlota_data:
|
|
||||||
if asset["code"] == spot.sig_refs[0]:
|
|
||||||
spot.sig_refs_names = [asset["name"]]
|
|
||||||
spot.dx_latitude = asset["y"]
|
|
||||||
spot.dx_longitude = asset["x"]
|
|
||||||
break
|
|
||||||
|
|
||||||
new_spots.append(spot)
|
|
||||||
return new_spots
|
|
||||||
@@ -1,38 +1,38 @@
|
|||||||
% rebase('webpage_base.tpl')
|
% rebase('webpage_base.tpl')
|
||||||
|
|
||||||
<div id="info-container" class="mt-4">
|
<div class="container main-container">
|
||||||
<h2 class="mt-4 mb-4">About Spothole</h2>
|
<div id="info-container" class="mt-4">
|
||||||
<p>Spothole is a utility to aggregate "spots" from amateur radio DX clusters and xOTA spotting sites, and provide an open JSON API as well as a website to browse the data.</p>
|
<h2 class="mt-4 mb-4">About Spothole</h2>
|
||||||
<p>While there are several other web-based interfaces to DX clusters, and sites that aggregate spots from various outdoor activity programmes for amateur radio, Spothole differentiates itself by supporting a large number of data sources, and by being "API first" rather than just providing a web front-end. This allows other software to be built on top of it.</p>
|
<p>Spothole is a utility to aggregate "spots" from amateur radio DX clusters and xOTA spotting sites, and provide an open JSON API as well as a website to browse the data.</p>
|
||||||
<p>The API is deliberately well-defined with an <a href="/apidocs/openapi.yml">OpenAPI specification</a> and <a href="/apidocs">API documentation</a>. The API delivers spots in a consistent format regardless of the data source, freeing developers from needing to know how each individual data source presents its data.</p>
|
<p>While there are several other web-based interfaces to DX clusters, and sites that aggregate spots from various outdoor activity programmes for amateur radio, Spothole differentiates itself by supporting a large number of data sources, and by being "API first" rather than just providing a web front-end. This allows other software to be built on top of it.</p>
|
||||||
<p>Spothole itself is also open source, Public Domain licenced code that anyone can take and modify. <a href="https://git.ianrenton.com/ian/metaspot/">The source code is here</a>. If you want to run your own copy of Spothole, or start modifying it for your own purposes, the <a href="https://git.ianrenton.com/ian/spothole/src/branch/main/README.md">README file</a> contains a description of how the software works and how it's laid out, as well as instructions for configuring systemd, nginx and anything else you might need to run your own server.</p>
|
<p>The API is deliberately well-defined with an <a href="/apidocs/openapi.yml">OpenAPI specification</a> and <a href="/apidocs">API documentation</a>. The API delivers spots in a consistent format regardless of the data source, freeing developers from needing to know how each individual data source presents its data.</p>
|
||||||
<p>Supported data sources include DX Clusters, the Reverse Beacon Network (RBN), the APRS Internet Service (APRS-IS), POTA, SOTA, WWFF, GMA, WWBOTA, HEMA, and Parks 'n' Peaks.</p>
|
<p>Spothole itself is also open source, Public Domain licenced code that anyone can take and modify. <a href="https://git.ianrenton.com/ian/metaspot/">The source code is here</a>. If you want to run your own copy of Spothole, or start modifying it for your own purposes, the <a href="https://git.ianrenton.com/ian/spothole/src/branch/main/README.md">README file</a> contains a description of how the software works and how it's laid out, as well as instructions for configuring systemd, nginx and anything else you might need to run your own server.</p>
|
||||||
<p>The software was written by <a href="https://ianrenton.com">Ian Renton, MØTRT</a> and other contributors. Full details are available in the README.</p>
|
<p>Supported data sources include DX Clusters, the Reverse Beacon Network (RBN), the APRS Internet Service (APRS-IS), POTA, SOTA, WWFF, GMA, WWBOTA, HEMA, and Parks 'n' Peaks.</p>
|
||||||
<p>This server is running Spothole version {{software_version}}.</p>
|
<p>The software was written by <a href="https://ianrenton.com">Ian Renton, MØTRT</a> and other contributors. Full details are available in the README.</p>
|
||||||
<h2 id="faq" class="mt-4">FAQ</h2>
|
<p>This server is running Spothole version {{software_version}}.</p>
|
||||||
<h4 class="mt-4">"Spots"? "DX Clusters"? What does any of this mean?</h4>
|
<h2 id="faq" class="mt-4">FAQ</h2>
|
||||||
<p>This is a tool for amateur ("ham") radio users. Many amateur radio operators like to make contacts with others who are doing something more interesting than sitting in their home "shack", such as people in rarely-seen countries, remote islands, or on mountaintops. Such operators are often "spotted", i.e. when someone speaks to them, they will put the details such as their operating frequency into an online system, to let others know where to find them. A DX Cluster is one type of those systems. Most outdoor radio awards programmes, such as "Parks on the Air" (POTA) have their own websites for posting spots.</p>
|
<h4 class="mt-4">"Spots"? "DX Clusters"? What does any of this mean?</h4>
|
||||||
<p>Spothole is an "aggregator" for those spots, so it checks lots of different services for data, and brings it all together in one place. So no matter what kinds of interesting spots you are looking for, you can find them here.</p>
|
<p>This is a tool for amateur ("ham") radio users. Many amateur radio operators like to make contacts with others who are doing something more interesting than sitting in their home "shack", such as people in rarely-seen countries, remote islands, or on mountaintops. Such operators are often "spotted", i.e. when someone speaks to them, they will put the details such as their operating frequency into an online system, to let others know where to find them. A DX Cluster is one type of those systems. Most outdoor radio awards programmes, such as "Parks on the Air" (POTA) have their own websites for posting spots.</p>
|
||||||
<p>As well as spots, it also provides a similar feed of "alerts". This is where amateur radio users who are going to interesting places soon will announce their intentions.</p>
|
<p>Spothole is an "aggregator" for those spots, so it checks lots of different services for data, and brings it all together in one place. So no matter what kinds of interesting spots you are looking for, you can find them here.</p>
|
||||||
<h4 class="mt-4">What are "DX", "DE" and modes?</h4>
|
<p>As well as spots, it also provides a similar feed of "alerts". This is where amateur radio users who are going to interesting places soon will announce their intentions.</p>
|
||||||
<p>In amateur radio terminology, the "DX" contact is the "interesting" one that is using the frequency shown. They might be on a remote island or just in a local park, but either way it's interesting enough that someone has "spotted" them. The callsign listed under "DE" is the person who spotted the "DX" operator. "Modes" are the type of communication they are using. You might see "CW" which is Morse Code, or voice "modes" like SSB or FM, or more exotic "data" modes which are used for computer-to-computer communication.</p>
|
<h4 class="mt-4">What are "DX", "DE" and modes?</h4>
|
||||||
<h4 class="mt-4">How is this better than DXheat, DXsummit, POTA's own website, etc?</h4>
|
<p>In amateur radio terminology, the "DX" contact is the "interesting" one that is using the frequency shown. They might be on a remote island or just in a local park, but either way it's interesting enough that someone has "spotted" them. The callsign listed under "DE" is the person who spotted the "DX" operator. "Modes" are the type of communication they are using. You might see "CW" which is Morse Code, or voice "modes" like SSB or FM, or more exotic "data" modes which are used for computer-to-computer communication.</p>
|
||||||
<p>It's probably not? But it's nice to have choice.</p>
|
<h4 class="mt-4">How is this better than DXheat, DXsummit, POTA's own website, etc?</h4>
|
||||||
<p>I think it's got two key advantages over those sites:</p>
|
<p>It's probably not? But it's nice to have choice.</p>
|
||||||
<ol><li>It provides a public, <a href="/apidocs">well-documented API</a> with an <a href="/apidocs/openapi.yml">OpenAPI specification</a>. Other sites don't have official APIs or don't bother documenting them publicly, because they want people to use their web page. I like Spothole's web page, but you don't have to use it—if you're a programmer, you can build your own software on Spothole's API. Spothole does the hard work of taking all the various data sources and providing a consistent, well-documented data set. You can then do the fun bit of writing your own application.</li>
|
<p>I think it's got two key advantages over those sites:</p>
|
||||||
<li>It grabs data from a lot more sources, and it's easy to add more. Since it's open source, anyone can contribute a new data source and share it with the community.</li></ol>
|
<ol><li>It provides a public, <a href="/apidocs">well-documented API</a> with an <a href="/apidocs/openapi.yml">OpenAPI specification</a>. Other sites don't have official APIs or don't bother documenting them publicly, because they want people to use their web page. I like Spothole's web page, but you don't have to use it—if you're a programmer, you can build your own software on Spothole's API. Spothole does the hard work of taking all the various data sources and providing a consistent, well-documented data set. You can then do the fun bit of writing your own application.</li>
|
||||||
<h4 class="mt-4">Why does this website ask me if I want to install it?</h4>
|
<li>It grabs data from a lot more sources, and it's easy to add more. Since it's open source, anyone can contribute a new data source and share it with the community.</li></ol>
|
||||||
<p>Spothole is a Progressive Web App, which means you can install it on an Android or iOS device by opening the site in Chrome or Safari respectively, and clicking "Install" on the pop-up panel. It'll only prompt you once, so if you dismiss the prompt and change your mind, you'll find an Install / Add to Home Screen option on your browser's menu.</p>
|
<h4 class="mt-4">Why does this website ask me if I want to install it?</h4>
|
||||||
<p>Installing Spothole on your phone is completely optional, the website works exactly the same way as the "app" does.</p>
|
<p>Spothole is a Progressive Web App, which means you can install it on an Android or iOS device by opening the site in Chrome or Safari respectively, and clicking "Install" on the pop-up panel. It'll only prompt you once, so if you dismiss the prompt and change your mind, you'll find an Install / Add to Home Screen option on your browser's menu.</p>
|
||||||
<h4 class="mt-4">Why hasn't my spot/alert shown up yet?</h4>
|
<p>Installing Spothole on your phone is completely optional, the website works exactly the same way as the "app" does.</p>
|
||||||
<p>To avoid putting too much load on the various servers that Spothole connects to, the Spothole server only polls them once every two minutes for spots, and once every hour for alerts. (Some sources, such as DX clusters, RBN, APRS-IS and WWBOTA use a non-polling mechanism, and their updates will therefore arrive more quickly.) Then if you are using the web interface, that has its own rate at which it reloads the data from Spothole, which is once a minute for spots or 30 minutes for alerts. So you could be waiting around three minutes to see a newly added spot, or 90 minutes to see a newly added alert.</p>
|
<h4 class="mt-4">What licence does Spothole use?</h4>
|
||||||
<h4 class="mt-4">What licence does Spothole use?</h4>
|
<p>Spothole's source code is licenced under the Public Domain. You can write a Spothole client, run your own server, modify it however you like, you can claim you wrote it and charge people £1000 for a copy, I don't really mind. (Please don't do the last one. But if you're using my code for something cool, it would be nice to hear from you!)</p>
|
||||||
<p>Spothole's source code is licenced under the Public Domain. You can write a Spothole client, run your own server, modify it however you like, you can claim you wrote it and charge people £1000 for a copy, I don't really mind. (Please don't do the last one. But if you're using my code for something cool, it would be nice to hear from you!)</p>
|
<h2 id="privacy" class="mt-4">Privacy</h2>
|
||||||
<h2 id="privacy" class="mt-4">Privacy</h2>
|
<p>Spothole collects no data about you, and there is no way to enter personally identifying information into the site apart from by spotting and alerting through Spothole or the various services it connects to. All spots and alerts are "timed out" and deleted from the system after a set interval, which by default is one hour for spots and one week for alerts.</p>
|
||||||
<p>Spothole collects no data about you, and there is no way to enter personally identifying information into the site apart from by spotting and alerting through Spothole or the various services it connects to. All spots and alerts are "timed out" and deleted from the system after a set interval, which by default is one hour for spots and one week for alerts.</p>
|
<p>Settings you select from Spothole's menus are sent to the server, in order to provide the data with the requested filters. They are also stored in your browser's local storage, so that your preferences are remembered between sessions.</p>
|
||||||
<p>Settings you select from Spothole's menus are sent to the server, in order to provide the data with the requested filters. They are also stored in your browser's local storage, so that your preferences are remembered between sessions.</p>
|
<p>There are no trackers, no ads, and no cookies.</p>
|
||||||
<p>There are no trackers, no ads, and no cookies.</p>
|
<p>Spothole is open source, so you can audit <a href="https://git.ianrenton.com/ian/spothole">the code</a> if you like.</p>
|
||||||
<p>Spothole is open source, so you can audit <a href="https://git.ianrenton.com/ian/spothole">the code</a> if you like.</p>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<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>
|
||||||
@@ -1,149 +1,151 @@
|
|||||||
% rebase('webpage_base.tpl')
|
% rebase('webpage_base.tpl')
|
||||||
|
|
||||||
<div class="mt-3">
|
<div class="container main-container mobile-no-gutters">
|
||||||
<div class="row">
|
<div class="mt-3">
|
||||||
<div class="col-auto me-auto pt-3">
|
<div class="row">
|
||||||
<p id="timing-container">Loading...</p>
|
<div class="col-auto me-auto pt-3">
|
||||||
</div>
|
<p id="timing-container">Loading...</p>
|
||||||
<div class="col-auto">
|
</div>
|
||||||
<p class="d-inline-flex gap-1">
|
<div class="col-auto">
|
||||||
<button id="filters-button" type="button" class="btn btn-outline-primary" data-bs-toggle="button" onclick="toggleFiltersPanel();"><i class="fa-solid fa-filter"></i> Filters</button>
|
<p class="d-inline-flex gap-1">
|
||||||
<button id="display-button" type="button" class="btn btn-outline-primary" data-bs-toggle="button" onclick="toggleDisplayPanel();"><i class="fa-solid fa-desktop"></i> Display</button>
|
<button id="filters-button" type="button" class="btn btn-outline-primary" data-bs-toggle="button" onclick="toggleFiltersPanel();"><i class="fa-solid fa-filter"></i> Filters</button>
|
||||||
</p>
|
<button id="display-button" type="button" class="btn btn-outline-primary" data-bs-toggle="button" onclick="toggleDisplayPanel();"><i class="fa-solid fa-desktop"></i> Display</button>
|
||||||
</div>
|
</p>
|
||||||
</div>
|
|
||||||
|
|
||||||
<div id="filters-area" class="appearing-panel card mb-3">
|
|
||||||
<div class="card-header text-white bg-primary">
|
|
||||||
<div class="row">
|
|
||||||
<div class="col-auto me-auto">
|
|
||||||
Filters
|
|
||||||
</div>
|
|
||||||
<div class="col-auto d-inline-flex">
|
|
||||||
<button id="close-filters-button" type="button" class="btn-close btn-close-white" aria-label="Close" onclick="closeFiltersPanel();"></button>
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
</div>
|
</div>
|
||||||
<div class="card-body">
|
|
||||||
<div class="row row-cols-1 row-cols-md-3 g-4">
|
<div id="filters-area" class="appearing-panel card mb-3">
|
||||||
<div class="col">
|
<div class="card-header text-white bg-primary">
|
||||||
<div class="card">
|
<div class="row">
|
||||||
<div class="card-body">
|
<div class="col-auto me-auto">
|
||||||
<h5 class="card-title">DX Continent</h5>
|
Filters
|
||||||
<p id="dx-continent-options" class="card-text spothole-card-text"></p>
|
</div>
|
||||||
</div>
|
<div class="col-auto d-inline-flex">
|
||||||
|
<button id="close-filters-button" type="button" class="btn-close btn-close-white" aria-label="Close" onclick="closeFiltersPanel();"></button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div class="col">
|
|
||||||
<div class="card">
|
</div>
|
||||||
<div class="card-body">
|
<div class="card-body">
|
||||||
<h5 class="card-title">Sources</h5>
|
<div class="row row-cols-1 row-cols-md-3 g-4">
|
||||||
<p id="source-options" class="card-text spothole-card-text"></p>
|
<div class="col">
|
||||||
|
<div class="card">
|
||||||
|
<div class="card-body">
|
||||||
|
<h5 class="card-title">DX Continent</h5>
|
||||||
|
<p id="dx-continent-options" class="card-text spothole-card-text"></p>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
<div class="col">
|
||||||
<div class="col">
|
<div class="card">
|
||||||
<div class="card">
|
<div class="card-body">
|
||||||
<div class="card-body">
|
<h5 class="card-title">Sources</h5>
|
||||||
<h5 class="card-title">Duration Limit <i class='fa-solid fa-circle-question' title='Some users create long-duration alerts for the period they will be generally in and around xOTA references, when they are not indending to be on the air most of the time. Use this control to restrict the maximum duration of spots that the software will display, and exclude any with a long duration, to avoid these filling up the list. By default, we allow DXpeditions to be displayed even if they are longer than this limit, because on a DXpedition the operators typically ARE on the air most of the time.'></i></h5>
|
<p id="source-options" class="card-text spothole-card-text"></p>
|
||||||
<p class="card-text spothole-card-text">
|
</div>
|
||||||
Hide any alerts lasting more than:<br/>
|
</div>
|
||||||
<select id="max-duration" class="storeable-select form-select" onclick="filtersUpdated();" style="width: 8em; display: inline-block;">
|
</div>
|
||||||
<option value="10800">3 hours</option>
|
<div class="col">
|
||||||
<option value="43200">12 hours</option>
|
<div class="card">
|
||||||
<option value="86400" selected>24 hours</option>
|
<div class="card-body">
|
||||||
<option value="604800">1 week</option>
|
<h5 class="card-title">Duration Limit <i class='fa-solid fa-circle-question' title='Some users create long-duration alerts for the period they will be generally in and around xOTA references, when they are not indending to be on the air most of the time. Use this control to restrict the maximum duration of spots that the software will display, and exclude any with a long duration, to avoid these filling up the list. By default, we allow DXpeditions to be displayed even if they are longer than this limit, because on a DXpedition the operators typically ARE on the air most of the time.'></i></h5>
|
||||||
<option value="2419200">4 weeks</option>
|
<p class="card-text spothole-card-text">
|
||||||
<option value="9999999999">No limit</option>
|
Hide any alerts lasting more than:<br/>
|
||||||
</select>
|
<select id="max-duration" class="storeable-select form-select" onclick="filtersUpdated();" style="width: 8em; display: inline-block;">
|
||||||
</p>
|
<option value="10800">3 hours</option>
|
||||||
<p class='card-text spothole-card-text' style='line-height: 1.5em !important;'>
|
<option value="43200">12 hours</option>
|
||||||
<input class="form-check-input storeable-checkbox" type="checkbox" value="" onclick="filtersUpdated();" id="dxpeditions_skip_max_duration_check" checked><label class="form-check-label ms-2" for="dxpeditions_skip_max_duration_check">Allow DXpeditions that are longer</label>
|
<option value="86400" selected>24 hours</option>
|
||||||
</p>
|
<option value="604800">1 week</option>
|
||||||
|
<option value="2419200">4 weeks</option>
|
||||||
|
<option value="9999999999">No limit</option>
|
||||||
|
</select>
|
||||||
|
</p>
|
||||||
|
<p class='card-text spothole-card-text' style='line-height: 1.5em !important;'>
|
||||||
|
<input class="form-check-input storeable-checkbox" type="checkbox" value="" onclick="filtersUpdated();" id="dxpeditions_skip_max_duration_check" checked><label class="form-check-label ms-2" for="dxpeditions_skip_max_duration_check">Allow DXpeditions that are longer</label>
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
|
||||||
|
|
||||||
<div id="display-area" class="appearing-panel card mb-3">
|
<div id="display-area" class="appearing-panel card mb-3">
|
||||||
<div class="card-header text-white bg-primary">
|
<div class="card-header text-white bg-primary">
|
||||||
<div class="row">
|
<div class="row">
|
||||||
<div class="col-auto me-auto">
|
<div class="col-auto me-auto">
|
||||||
Display
|
Display
|
||||||
</div>
|
</div>
|
||||||
<div class="col-auto d-inline-flex">
|
<div class="col-auto d-inline-flex">
|
||||||
<button id="close-display-button" type="button" class="btn-close btn-close-white" aria-label="Close" onclick="closeDisplayPanel();"></button>
|
<button id="close-display-button" type="button" class="btn-close btn-close-white" aria-label="Close" onclick="closeDisplayPanel();"></button>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
</div>
|
</div>
|
||||||
|
<div class="card-body">
|
||||||
</div>
|
<div id="display-container" class="row row-cols-1 row-cols-md-3 g-4">
|
||||||
<div class="card-body">
|
<div class="col">
|
||||||
<div id="display-container" class="row row-cols-1 row-cols-md-3 g-4">
|
<div class="card">
|
||||||
<div class="col">
|
<div class="card-body">
|
||||||
<div class="card">
|
<h5 class="card-title">Time Zone</h5>
|
||||||
<div class="card-body">
|
<p class="card-text spothole-card-text"> Use
|
||||||
<h5 class="card-title">Time Zone</h5>
|
<select id="timeZone" class="storeable-select form-select ms-2 me-2 d-inline-block" oninput="timeZoneUpdated();" style="width: 8em; display: inline-block;">
|
||||||
<p class="card-text spothole-card-text"> Use
|
<option value="UTC" selected>UTC</option>
|
||||||
<select id="timeZone" class="storeable-select form-select ms-2 me-2 d-inline-block" oninput="timeZoneUpdated();" style="width: 8em; display: inline-block;">
|
<option value="local">Local time</option>
|
||||||
<option value="UTC" selected>UTC</option>
|
</select>
|
||||||
<option value="local">Local time</option>
|
</p>
|
||||||
</select>
|
</div>
|
||||||
</p>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
<div class="col">
|
||||||
<div class="col">
|
<div class="card">
|
||||||
<div class="card">
|
<div class="card-body">
|
||||||
<div class="card-body">
|
<h5 class="card-title">Number of Alerts</h5>
|
||||||
<h5 class="card-title">Number of Alerts</h5>
|
<p class="card-text spothole-card-text">Show up to
|
||||||
<p class="card-text spothole-card-text">Show up to
|
<select id="alerts-to-fetch" class="storeable-select form-select ms-2" oninput="filtersUpdated();" style="width: 5em;display: inline-block;">
|
||||||
<select id="alerts-to-fetch" class="storeable-select form-select ms-2" oninput="filtersUpdated();" style="width: 5em;display: inline-block;">
|
<option value="25">25</option>
|
||||||
<option value="25">25</option>
|
<option value="50">50</option>
|
||||||
<option value="50">50</option>
|
<option value="100" selected>100</option>
|
||||||
<option value="100" selected>100</option>
|
<option value="200">200</option>
|
||||||
<option value="200">200</option>
|
<option value="500">500</option>
|
||||||
<option value="500">500</option>
|
</select>
|
||||||
</select>
|
alerts
|
||||||
alerts
|
</p>
|
||||||
</p>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
<div class="col">
|
||||||
<div class="col">
|
<div class="card">
|
||||||
<div class="card">
|
<div class="card-body">
|
||||||
<div class="card-body">
|
<h5 class="card-title">Table Data</h5>
|
||||||
<h5 class="card-title">Table Data</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="tableShowStartTime" value="tableShowStartTime" oninput="columnsUpdated();" checked>
|
||||||
<input class="form-check-input storeable-checkbox" type="checkbox" id="tableShowStartTime" value="tableShowStartTime" oninput="columnsUpdated();" checked>
|
<label class="form-check-label" for="tableShowStartTime">Start Time</label>
|
||||||
<label class="form-check-label" for="tableShowStartTime">Start Time</label>
|
</div>
|
||||||
</div>
|
<div class="form-check form-check-inline">
|
||||||
<div class="form-check form-check-inline">
|
<input class="form-check-input storeable-checkbox" type="checkbox" id="tableShowEndTime" value="tableShowEndTime" oninput="columnsUpdated();" checked>
|
||||||
<input class="form-check-input storeable-checkbox" type="checkbox" id="tableShowEndTime" value="tableShowEndTime" oninput="columnsUpdated();" checked>
|
<label class="form-check-label" for="tableShowEndTime">End Time</label>
|
||||||
<label class="form-check-label" for="tableShowEndTime">End Time</label>
|
</div>
|
||||||
</div>
|
<div class="form-check form-check-inline">
|
||||||
<div class="form-check form-check-inline">
|
<input class="form-check-input storeable-checkbox" type="checkbox" id="tableShowDX" value="tableShowDX" oninput="columnsUpdated();" checked>
|
||||||
<input class="form-check-input storeable-checkbox" type="checkbox" id="tableShowDX" value="tableShowDX" oninput="columnsUpdated();" checked>
|
<label class="form-check-label" for="tableShowDX">DX</label>
|
||||||
<label class="form-check-label" for="tableShowDX">DX</label>
|
</div>
|
||||||
</div>
|
<div class="form-check form-check-inline">
|
||||||
<div class="form-check form-check-inline">
|
<input class="form-check-input storeable-checkbox" type="checkbox" id="tableShowFreqsModes" value="tableShowFreqsModes" oninput="columnsUpdated();" checked>
|
||||||
<input class="form-check-input storeable-checkbox" type="checkbox" id="tableShowFreqsModes" value="tableShowFreqsModes" oninput="columnsUpdated();" checked>
|
<label class="form-check-label" for="tableShowFreqsModes">Frequencies & Modes</label>
|
||||||
<label class="form-check-label" for="tableShowFreqsModes">Frequencies & Modes</label>
|
</div>
|
||||||
</div>
|
<div class="form-check form-check-inline">
|
||||||
<div class="form-check form-check-inline">
|
<input class="form-check-input storeable-checkbox" type="checkbox" id="tableShowComment" value="tableShowComment" oninput="columnsUpdated();" checked>
|
||||||
<input class="form-check-input storeable-checkbox" type="checkbox" id="tableShowComment" value="tableShowComment" oninput="columnsUpdated();" checked>
|
<label class="form-check-label" for="tableShowComment">Comment</label>
|
||||||
<label class="form-check-label" for="tableShowComment">Comment</label>
|
</div>
|
||||||
</div>
|
<div class="form-check form-check-inline">
|
||||||
<div class="form-check form-check-inline">
|
<input class="form-check-input storeable-checkbox" type="checkbox" id="tableShowSource" value="tableShowSource" oninput="columnsUpdated();" checked>
|
||||||
<input class="form-check-input storeable-checkbox" type="checkbox" id="tableShowSource" value="tableShowSource" oninput="columnsUpdated();" checked>
|
<label class="form-check-label" for="tableShowSource">Source</label>
|
||||||
<label class="form-check-label" for="tableShowSource">Source</label>
|
</div>
|
||||||
</div>
|
<div class="form-check form-check-inline">
|
||||||
<div class="form-check form-check-inline">
|
<input class="form-check-input storeable-checkbox" type="checkbox" id="tableShowRef" value="tableShowRef" oninput="columnsUpdated();" checked>
|
||||||
<input class="form-check-input storeable-checkbox" type="checkbox" id="tableShowRef" value="tableShowRef" oninput="columnsUpdated();" checked>
|
<label class="form-check-label" for="tableShowRef">Ref.</label>
|
||||||
<label class="form-check-label" for="tableShowRef">Ref.</label>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -151,10 +153,10 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<div id="table-container"></div>
|
||||||
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div id="table-container"></div>
|
|
||||||
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<script src="/js/common.js"></script>
|
<script src="/js/common.js"></script>
|
||||||
|
|||||||
@@ -1,5 +1,8 @@
|
|||||||
% rebase('webpage_base.tpl')
|
% rebase('webpage_base.tpl')
|
||||||
|
|
||||||
<redoc spec-url="/apidocs/openapi.yml"></redoc>
|
<div class="container main-container">
|
||||||
|
<redoc spec-url="/apidocs/openapi.yml"></redoc>
|
||||||
|
</div>
|
||||||
|
|
||||||
<script src="https://cdn.redoc.ly/redoc/latest/bundles/redoc.standalone.js"> </script>
|
<script src="https://cdn.redoc.ly/redoc/latest/bundles/redoc.standalone.js"> </script>
|
||||||
<script>$(document).ready(function() { $("#nav-link-api").addClass("active"); }); <!-- highlight active page in nav --></script>
|
<script>$(document).ready(function() { $("#nav-link-api").addClass("active"); }); <!-- highlight active page in nav --></script>
|
||||||
@@ -1,117 +0,0 @@
|
|||||||
% rebase('webpage_base.tpl')
|
|
||||||
|
|
||||||
<div class="mt-3">
|
|
||||||
<div class="row">
|
|
||||||
<div class="col-auto me-auto pt-3">
|
|
||||||
<p id="timing-container">Loading...</p>
|
|
||||||
</div>
|
|
||||||
<div class="col-auto">
|
|
||||||
<p class="d-inline-flex gap-1">
|
|
||||||
<button id="filters-button" type="button" class="btn btn-outline-primary" data-bs-toggle="button" onclick="toggleFiltersPanel();"><i class="fa-solid fa-filter"></i> Filters</button>
|
|
||||||
<button id="display-button" type="button" class="btn btn-outline-primary" data-bs-toggle="button" onclick="toggleDisplayPanel();"><i class="fa-solid fa-desktop"></i> Display</button>
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div id="filters-area" class="appearing-panel card mb-3">
|
|
||||||
<div class="card-header text-white bg-primary">
|
|
||||||
<div class="row">
|
|
||||||
<div class="col-auto me-auto">
|
|
||||||
Filters
|
|
||||||
</div>
|
|
||||||
<div class="col-auto d-inline-flex">
|
|
||||||
<button id="close-filters-button" type="button" class="btn-close btn-close-white" aria-label="Close" onclick="closeFiltersPanel();"></button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
</div>
|
|
||||||
<div class="card-body">
|
|
||||||
<div class="row row-cols-1 g-4 mb-4">
|
|
||||||
<div class="col">
|
|
||||||
<div class="card">
|
|
||||||
<div class="card-body">
|
|
||||||
<h5 class="card-title">Bands</h5>
|
|
||||||
<p id="band-options" class="card-text spothole-card-text"></p>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div class="row row-cols-1 row-cols-md-4 g-4">
|
|
||||||
<div class="col">
|
|
||||||
<div class="card">
|
|
||||||
<div class="card-body">
|
|
||||||
<h5 class="card-title">DX Continent</h5>
|
|
||||||
<p id="dx-continent-options" class="card-text spothole-card-text"></p>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div class="col">
|
|
||||||
<div class="card">
|
|
||||||
<div class="card-body">
|
|
||||||
<h5 class="card-title">DE Continent</h5>
|
|
||||||
<p id="de-continent-options" class="card-text spothole-card-text"></p>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div class="col">
|
|
||||||
<div class="card">
|
|
||||||
<div class="card-body">
|
|
||||||
<h5 class="card-title">Modes</h5>
|
|
||||||
<p id="mode-options" class="card-text spothole-card-text"></p>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div class="col">
|
|
||||||
<div class="card">
|
|
||||||
<div class="card-body">
|
|
||||||
<h5 class="card-title">Sources</h5>
|
|
||||||
<p id="source-options" class="card-text spothole-card-text"></p>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div id="display-area" class="appearing-panel card mb-3">
|
|
||||||
<div class="card-header text-white bg-primary">
|
|
||||||
<div class="row">
|
|
||||||
<div class="col-auto me-auto">
|
|
||||||
Display
|
|
||||||
</div>
|
|
||||||
<div class="col-auto d-inline-flex">
|
|
||||||
<button id="close-display-button" type="button" class="btn-close btn-close-white" aria-label="Close" onclick="closeDisplayPanel();"></button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
</div>
|
|
||||||
<div class="card-body">
|
|
||||||
<div id="display-container" class="row row-cols-1 row-cols-md-4 g-4">
|
|
||||||
<div class="col">
|
|
||||||
<div class="card">
|
|
||||||
<div class="card-body">
|
|
||||||
<h5 class="card-title">Spot Age</h5>
|
|
||||||
<p class="card-text spothole-card-text">Last
|
|
||||||
<select id="max-spot-age" class="storeable-select form-select ms-2 me-2 d-inline-block" oninput="filtersUpdated();" style="width: 5em; display: inline-block;">
|
|
||||||
<option value="300">5</option>
|
|
||||||
<option value="600">10</option>
|
|
||||||
<option value="1800" selected>30</option>
|
|
||||||
<option value="3600">60</option>
|
|
||||||
</select>
|
|
||||||
minutes
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div id="bands-container"></div>
|
|
||||||
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<script src="/js/common.js"></script>
|
|
||||||
<script src="/js/spotandmap.js"></script>
|
|
||||||
<script src="/js/bands.js"></script>
|
|
||||||
<script>$(document).ready(function() { $("#nav-link-bands").addClass("active"); }); <!-- highlight active page in nav --></script>
|
|
||||||
@@ -31,7 +31,6 @@
|
|||||||
<link href="/fa/css/solid.min.css" rel="stylesheet" />
|
<link href="/fa/css/solid.min.css" rel="stylesheet" />
|
||||||
|
|
||||||
<link rel="icon" type="image/png" href="/img/icon-512.png">
|
<link rel="icon" type="image/png" href="/img/icon-512.png">
|
||||||
<link rel="apple-touch-icon" href="img/icon-512-pwa.png">
|
|
||||||
<link rel="alternate icon" type="image/png" href="/img/icon-192.png">
|
<link rel="alternate icon" type="image/png" href="/img/icon-192.png">
|
||||||
<link rel="alternate icon" type="image/png" href="/img/icon-32.png">
|
<link rel="alternate icon" type="image/png" href="/img/icon-32.png">
|
||||||
<link rel="alternate icon" type="image/png" href="/img/icon-16.png">
|
<link rel="alternate icon" type="image/png" href="/img/icon-16.png">
|
||||||
@@ -58,17 +57,17 @@
|
|||||||
</button>
|
</button>
|
||||||
<div class="collapse navbar-collapse" id="navbarTogglerDemo02">
|
<div class="collapse navbar-collapse" id="navbarTogglerDemo02">
|
||||||
<ul class="navbar-nav me-auto mb-2 mb-lg-0">
|
<ul class="navbar-nav me-auto mb-2 mb-lg-0">
|
||||||
<li class="nav-item ms-4"><a href="/" class="nav-link" id="nav-link-spots"><i class="fa-solid fa-tower-cell"></i> Spots</a></li>
|
<li class="nav-item ms-4"><a href="/" class="nav-link" id="nav-link-spots">Spots</a></li>
|
||||||
<li class="nav-item ms-4"><a href="/map" class="nav-link" id="nav-link-map"><i class="fa-solid fa-map"></i> Map</a></li>
|
<li class="nav-item ms-4"><a href="/map" class="nav-link" id="nav-link-map">Map</a></li>
|
||||||
<li class="nav-item ms-4"><a href="/bands" class="nav-link" id="nav-link-bands"><i class="fa-solid fa-ruler-vertical"></i> Bands</a></li>
|
<li class="nav-item ms-4"><a href="/alerts" class="nav-link" id="nav-link-alerts">Alerts</a></li>
|
||||||
<li class="nav-item ms-4"><a href="/alerts" class="nav-link" id="nav-link-alerts"><i class="fa-solid fa-bell"></i> Alerts</a></li>
|
<li class="nav-item ms-4"><a href="/status" class="nav-link" id="nav-link-status">Status</a></li>
|
||||||
<li class="nav-item ms-4"><a href="/status" class="nav-link" id="nav-link-status"><i class="fa-solid fa-chart-simple"></i> Status</a></li>
|
<li class="nav-item ms-4"><a href="/about" class="nav-link" id="nav-link-about">About</a></li>
|
||||||
<li class="nav-item ms-4"><a href="/about" class="nav-link" id="nav-link-about"><i class="fa-solid fa-circle-info"></i> About</a></li>
|
<li class="nav-item ms-4"><a href="/apidocs" class="nav-link" id="nav-link-api">API</a></li>
|
||||||
<li class="nav-item ms-4"><a href="/apidocs" class="nav-link" id="nav-link-api"><i class="fa-solid fa-gear"></i> API</a></li>
|
|
||||||
</ul>
|
</ul>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</nav>
|
</nav>
|
||||||
|
</div>
|
||||||
|
|
||||||
<main>
|
<main>
|
||||||
|
|
||||||
@@ -76,6 +75,7 @@
|
|||||||
|
|
||||||
</main>
|
</main>
|
||||||
|
|
||||||
|
<div class="container">
|
||||||
<div class="hideonmobile hideonmap">
|
<div class="hideonmobile hideonmap">
|
||||||
<footer class="d-flex flex-wrap justify-content-between align-items-center py-3 my-4 border-top">
|
<footer class="d-flex flex-wrap justify-content-between align-items-center py-3 my-4 border-top">
|
||||||
<p class="col-md-4 mb-0 text-body-secondary">Made with love by <a href="https://ianrenton.com" class="text-body-secondary">Ian, MØTRT</a> and other contributors.</p>
|
<p class="col-md-4 mb-0 text-body-secondary">Made with love by <a href="https://ianrenton.com" class="text-body-secondary">Ian, MØTRT</a> and other contributors.</p>
|
||||||
|
|||||||
@@ -1,115 +1,117 @@
|
|||||||
% rebase('webpage_base.tpl')
|
% rebase('webpage_base.tpl')
|
||||||
|
|
||||||
<div id="map">
|
<div class="container main-container mobile-no-gutters">
|
||||||
<div class="mt-3 px-3" style="z-index: 1002; position: relative;">
|
<div id="map">
|
||||||
<div class="row">
|
<div class="mt-3 px-3" style="z-index: 1002; position: relative;">
|
||||||
<div class="col-auto me-auto pt-3"></div>
|
<div class="row">
|
||||||
<div class="col-auto">
|
<div class="col-auto me-auto pt-3"></div>
|
||||||
<p class="d-inline-flex gap-1">
|
<div class="col-auto">
|
||||||
<button id="filters-button" type="button" class="btn btn-outline-primary" data-bs-toggle="button" onclick="toggleFiltersPanel();"><i class="fa-solid fa-filter"></i> Filters</button>
|
<p class="d-inline-flex gap-1">
|
||||||
<button id="display-button" type="button" class="btn btn-outline-primary" data-bs-toggle="button" onclick="toggleDisplayPanel();"><i class="fa-solid fa-desktop"></i> Display</button>
|
<button id="filters-button" type="button" class="btn btn-outline-primary" data-bs-toggle="button" onclick="toggleFiltersPanel();"><i class="fa-solid fa-filter"></i> Filters</button>
|
||||||
</p>
|
<button id="display-button" type="button" class="btn btn-outline-primary" data-bs-toggle="button" onclick="toggleDisplayPanel();"><i class="fa-solid fa-desktop"></i> Display</button>
|
||||||
</div>
|
</p>
|
||||||
</div>
|
|
||||||
|
|
||||||
<div id="filters-area" class="appearing-panel card mb-3">
|
|
||||||
<div class="card-header text-white bg-primary">
|
|
||||||
<div class="row">
|
|
||||||
<div class="col-auto me-auto">
|
|
||||||
Filters
|
|
||||||
</div>
|
|
||||||
<div class="col-auto d-inline-flex">
|
|
||||||
<button id="close-filters-button" type="button" class="btn-close btn-close-white" aria-label="Close" onclick="closeFiltersPanel();"></button>
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
</div>
|
</div>
|
||||||
<div class="card-body">
|
|
||||||
<div class="row row-cols-1 g-4 mb-4">
|
<div id="filters-area" class="appearing-panel card mb-3">
|
||||||
<div class="col">
|
<div class="card-header text-white bg-primary">
|
||||||
<div class="card">
|
<div class="row">
|
||||||
<div class="card-body">
|
<div class="col-auto me-auto">
|
||||||
<h5 class="card-title">Bands</h5>
|
Filters
|
||||||
<p id="band-options" class="card-text spothole-card-text"></p>
|
</div>
|
||||||
</div>
|
<div class="col-auto d-inline-flex">
|
||||||
|
<button id="close-filters-button" type="button" class="btn-close btn-close-white" aria-label="Close" onclick="closeFiltersPanel();"></button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
</div>
|
</div>
|
||||||
<div class="row row-cols-1 row-cols-md-4 g-4">
|
<div class="card-body">
|
||||||
<div class="col">
|
<div class="row row-cols-1 g-4 mb-4">
|
||||||
<div class="card">
|
<div class="col">
|
||||||
<div class="card-body">
|
<div class="card">
|
||||||
<h5 class="card-title">DX Continent</h5>
|
<div class="card-body">
|
||||||
<p id="dx-continent-options" class="card-text spothole-card-text"></p>
|
<h5 class="card-title">Bands</h5>
|
||||||
|
<p id="band-options" class="card-text spothole-card-text"></p>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div class="col">
|
<div class="row row-cols-1 row-cols-md-4 g-4">
|
||||||
<div class="card">
|
<div class="col">
|
||||||
<div class="card-body">
|
<div class="card">
|
||||||
<h5 class="card-title">DE Continent</h5>
|
<div class="card-body">
|
||||||
<p id="de-continent-options" class="card-text spothole-card-text"></p>
|
<h5 class="card-title">DX Continent</h5>
|
||||||
|
<p id="dx-continent-options" class="card-text spothole-card-text"></p>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
<div class="col">
|
||||||
<div class="col">
|
<div class="card">
|
||||||
<div class="card">
|
<div class="card-body">
|
||||||
<div class="card-body">
|
<h5 class="card-title">DE Continent</h5>
|
||||||
<h5 class="card-title">Modes</h5>
|
<p id="de-continent-options" class="card-text spothole-card-text"></p>
|
||||||
<p id="mode-options" class="card-text spothole-card-text"></p>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
<div class="col">
|
||||||
<div class="col">
|
<div class="card">
|
||||||
<div class="card">
|
<div class="card-body">
|
||||||
<div class="card-body">
|
<h5 class="card-title">Modes</h5>
|
||||||
<h5 class="card-title">Sources</h5>
|
<p id="mode-options" class="card-text spothole-card-text"></p>
|
||||||
<p id="source-options" class="card-text spothole-card-text"></p>
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="col">
|
||||||
|
<div class="card">
|
||||||
|
<div class="card-body">
|
||||||
|
<h5 class="card-title">Sources</h5>
|
||||||
|
<p id="source-options" class="card-text spothole-card-text"></p>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
|
||||||
|
|
||||||
<div id="display-area" class="appearing-panel card mb-3">
|
<div id="display-area" class="appearing-panel card mb-3">
|
||||||
<div class="card-header text-white bg-primary">
|
<div class="card-header text-white bg-primary">
|
||||||
<div class="row">
|
<div class="row">
|
||||||
<div class="col-auto me-auto">
|
<div class="col-auto me-auto">
|
||||||
Display
|
Display
|
||||||
</div>
|
</div>
|
||||||
<div class="col-auto d-inline-flex">
|
<div class="col-auto d-inline-flex">
|
||||||
<button id="close-display-button" type="button" class="btn-close btn-close-white" aria-label="Close" onclick="closeDisplayPanel();"></button>
|
<button id="close-display-button" type="button" class="btn-close btn-close-white" aria-label="Close" onclick="closeDisplayPanel();"></button>
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
</div>
|
|
||||||
<div class="card-body">
|
|
||||||
<div id="display-container" class="row row-cols-1 row-cols-md-4 g-4">
|
|
||||||
<div class="col">
|
|
||||||
<div class="card">
|
|
||||||
<div class="card-body">
|
|
||||||
<h5 class="card-title">Spot Age</h5>
|
|
||||||
<p class="card-text spothole-card-text">Last
|
|
||||||
<select id="max-spot-age" class="storeable-select form-select ms-2 me-2 d-inline-block" oninput="filtersUpdated();" style="width: 5em; display: inline-block;">
|
|
||||||
<option value="300">5</option>
|
|
||||||
<option value="600">10</option>
|
|
||||||
<option value="1800" selected>30</option>
|
|
||||||
<option value="3600">60</option>
|
|
||||||
</select>
|
|
||||||
minutes
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div class="col">
|
|
||||||
<div class="card">
|
</div>
|
||||||
<div class="card-body">
|
<div class="card-body">
|
||||||
<h5 class="card-title">Map Features</h5>
|
<div id="display-container" class="row row-cols-1 row-cols-md-4 g-4">
|
||||||
<div class="form-group">
|
<div class="col">
|
||||||
<div class="form-check form-check-inline">
|
<div class="card">
|
||||||
<input class="form-check-input storeable-checkbox" type="checkbox" id="mapShowGeodesics" value="mapShowGeodesics" oninput="displayUpdated();">
|
<div class="card-body">
|
||||||
<label class="form-check-label" for="mapShowGeodesics">Geodesic Lines</label>
|
<h5 class="card-title">Spot Age</h5>
|
||||||
|
<p class="card-text spothole-card-text">Last
|
||||||
|
<select id="max-spot-age" class="storeable-select form-select ms-2 me-2 d-inline-block" oninput="filtersUpdated();" style="width: 5em; display: inline-block;">
|
||||||
|
<option value="300">5</option>
|
||||||
|
<option value="600">10</option>
|
||||||
|
<option value="1800" selected>30</option>
|
||||||
|
<option value="3600">60</option>
|
||||||
|
</select>
|
||||||
|
minutes
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="col">
|
||||||
|
<div class="card">
|
||||||
|
<div class="card-body">
|
||||||
|
<h5 class="card-title">Map Features</h5>
|
||||||
|
<div class="form-group">
|
||||||
|
<div class="form-check form-check-inline">
|
||||||
|
<input class="form-check-input storeable-checkbox" type="checkbox" id="mapShowGeodesics" value="mapShowGeodesics" oninput="displayUpdated();">
|
||||||
|
<label class="form-check-label" for="mapShowGeodesics">Geodesic Lines</label>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -1,240 +1,242 @@
|
|||||||
% rebase('webpage_base.tpl')
|
% rebase('webpage_base.tpl')
|
||||||
|
|
||||||
<div id="intro-box" class="mt-3">
|
<div class="container main-container mobile-no-gutters">
|
||||||
<div class="alert alert-primary alert-dismissible fade show" role="alert">
|
<div id="intro-box" class="mt-3">
|
||||||
<i class="fa-solid fa-circle-info"></i> <strong>What is Spothole?</strong><br/>Spothole is an aggregator of amateur radio spots from DX clusters and outdoor activity programmes. It's free for anyone to use and includes an API that developers can build other applications on. For more information, check out the <a href="/about" class="alert-link">"About" page</a>. If that sounds like nonsense to you, you can visit <a href="/about#faq" class="alert-link">the FAQ section</a> to learn more.
|
<div class="alert alert-primary alert-dismissible fade show" role="alert">
|
||||||
<button type="button" id="intro-box-dismiss" class="btn-close" data-bs-dismiss="alert" aria-label="Close"></button>
|
<i class="fa-solid fa-circle-info"></i> <strong>What is Spothole?</strong><br/>Spothole is an aggregator of amateur radio spots from DX clusters and outdoor activity programmes. It's free for anyone to use and includes an API that developers can build other applications on. For more information, check out the <a href="/about" class="alert-link">"About" page</a>. If that sounds like nonsense to you, you can visit <a href="/about#faq" class="alert-link">the FAQ section</a> to learn more.
|
||||||
</div>
|
<button type="button" id="intro-box-dismiss" class="btn-close" data-bs-dismiss="alert" aria-label="Close"></button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="mt-3">
|
|
||||||
<div class="row">
|
|
||||||
<div class="col-auto me-auto pt-3">
|
|
||||||
<p id="timing-container">Loading...</p>
|
|
||||||
</div>
|
|
||||||
<div class="col-auto">
|
|
||||||
<p class="d-inline-flex gap-1">
|
|
||||||
<button id="add-spot-button" type="button" class="btn btn-outline-primary" data-bs-toggle="button" onclick="toggleAddSpotPanel();"><i class="fa-solid fa-comment"></i> Add Spot</button>
|
|
||||||
<button id="filters-button" type="button" class="btn btn-outline-primary" data-bs-toggle="button" onclick="toggleFiltersPanel();"><i class="fa-solid fa-filter"></i> Filters</button>
|
|
||||||
<button id="display-button" type="button" class="btn btn-outline-primary" data-bs-toggle="button" onclick="toggleDisplayPanel();"><i class="fa-solid fa-desktop"></i> Display</button>
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div id="filters-area" class="appearing-panel card mb-3">
|
<div class="mt-3">
|
||||||
<div class="card-header text-white bg-primary">
|
<div class="row">
|
||||||
<div class="row">
|
<div class="col-auto me-auto pt-3">
|
||||||
<div class="col-auto me-auto">
|
<p id="timing-container">Loading...</p>
|
||||||
Filters
|
|
||||||
</div>
|
|
||||||
<div class="col-auto d-inline-flex">
|
|
||||||
<button id="close-filters-button" type="button" class="btn-close btn-close-white" aria-label="Close" onclick="closeFiltersPanel();"></button>
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
|
<div class="col-auto">
|
||||||
</div>
|
<p class="d-inline-flex gap-1">
|
||||||
<div class="card-body">
|
<button id="add-spot-button" type="button" class="btn btn-outline-primary" data-bs-toggle="button" onclick="toggleAddSpotPanel();"><i class="fa-solid fa-comment"></i> Add Spot</button>
|
||||||
<div class="row row-cols-1 g-4 mb-4">
|
<button id="filters-button" type="button" class="btn btn-outline-primary" data-bs-toggle="button" onclick="toggleFiltersPanel();"><i class="fa-solid fa-filter"></i> Filters</button>
|
||||||
<div class="col">
|
<button id="display-button" type="button" class="btn btn-outline-primary" data-bs-toggle="button" onclick="toggleDisplayPanel();"><i class="fa-solid fa-desktop"></i> Display</button>
|
||||||
<div class="card">
|
</p>
|
||||||
<div class="card-body">
|
|
||||||
<h5 class="card-title">Bands</h5>
|
|
||||||
<p id="band-options" class="card-text spothole-card-text"></p>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div class="row row-cols-1 row-cols-md-4 g-4">
|
|
||||||
<div class="col">
|
|
||||||
<div class="card">
|
|
||||||
<div class="card-body">
|
|
||||||
<h5 class="card-title">DX Continent</h5>
|
|
||||||
<p id="dx-continent-options" class="card-text spothole-card-text"></p>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div class="col">
|
|
||||||
<div class="card">
|
|
||||||
<div class="card-body">
|
|
||||||
<h5 class="card-title">DE Continent</h5>
|
|
||||||
<p id="de-continent-options" class="card-text spothole-card-text"></p>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div class="col">
|
|
||||||
<div class="card">
|
|
||||||
<div class="card-body">
|
|
||||||
<h5 class="card-title">Modes</h5>
|
|
||||||
<p id="mode-options" class="card-text spothole-card-text"></p>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div class="col">
|
|
||||||
<div class="card">
|
|
||||||
<div class="card-body">
|
|
||||||
<h5 class="card-title">Sources</h5>
|
|
||||||
<p id="source-options" class="card-text spothole-card-text"></p>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
|
||||||
|
|
||||||
<div id="display-area" class="appearing-panel card mb-3">
|
<div id="filters-area" class="appearing-panel card mb-3">
|
||||||
<div class="card-header text-white bg-primary">
|
<div class="card-header text-white bg-primary">
|
||||||
<div class="row">
|
<div class="row">
|
||||||
<div class="col-auto me-auto">
|
<div class="col-auto me-auto">
|
||||||
Display
|
Filters
|
||||||
</div>
|
</div>
|
||||||
<div class="col-auto d-inline-flex">
|
<div class="col-auto d-inline-flex">
|
||||||
<button id="close-display-button" type="button" class="btn-close btn-close-white" aria-label="Close" onclick="closeDisplayPanel();"></button>
|
<button id="close-filters-button" type="button" class="btn-close btn-close-white" aria-label="Close" onclick="closeFiltersPanel();"></button>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
</div>
|
</div>
|
||||||
|
<div class="card-body">
|
||||||
</div>
|
<div class="row row-cols-1 g-4 mb-4">
|
||||||
<div class="card-body">
|
<div class="col">
|
||||||
<div id="display-container" class="row row-cols-1 row-cols-md-4 g-4">
|
<div class="card">
|
||||||
<div class="col">
|
<div class="card-body">
|
||||||
<div class="card">
|
<h5 class="card-title">Bands</h5>
|
||||||
<div class="card-body">
|
<p id="band-options" class="card-text spothole-card-text"></p>
|
||||||
<h5 class="card-title">Time Zone</h5>
|
|
||||||
<p class="card-text spothole-card-text"> Use
|
|
||||||
<select id="timeZone" class="storeable-select form-select ms-2 me-2 d-inline-block" oninput="timeZoneUpdated();" style="width: 8em; display: inline-block;">
|
|
||||||
<option value="UTC" selected>UTC</option>
|
|
||||||
<option value="local">Local time</option>
|
|
||||||
</select>
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div class="col">
|
|
||||||
<div class="card">
|
|
||||||
<div class="card-body">
|
|
||||||
<h5 class="card-title">Number of Spots</h5>
|
|
||||||
<p class="card-text spothole-card-text">Show up to
|
|
||||||
<select id="spots-to-fetch" class="storeable-select form-select ms-2 me-2 d-inline-block" oninput="filtersUpdated();" style="width: 5em; display: inline-block;">
|
|
||||||
<option value="10">10</option>
|
|
||||||
<option value="25">25</option>
|
|
||||||
<option value="50" selected>50</option>
|
|
||||||
<option value="100">100</option>
|
|
||||||
</select>
|
|
||||||
spots
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div class="col">
|
|
||||||
<div class="card">
|
|
||||||
<div class="card-body">
|
|
||||||
<h5 class="card-title">Table Columns</h5>
|
|
||||||
<div class="form-group">
|
|
||||||
<div class="form-check form-check-inline">
|
|
||||||
<input class="form-check-input storeable-checkbox" type="checkbox" id="tableShowTime" value="tableShowTime" oninput="columnsUpdated();" checked>
|
|
||||||
<label class="form-check-label" for="tableShowTime">Time</label>
|
|
||||||
</div>
|
|
||||||
<div class="form-check form-check-inline">
|
|
||||||
<input class="form-check-input storeable-checkbox" type="checkbox" id="tableShowDX" value="tableShowDX" oninput="columnsUpdated();" checked>
|
|
||||||
<label class="form-check-label" for="tableShowDX">DX</label>
|
|
||||||
</div>
|
|
||||||
<div class="form-check form-check-inline">
|
|
||||||
<input class="form-check-input storeable-checkbox" type="checkbox" id="tableShowFreq" value="tableShowFreq" oninput="columnsUpdated();" checked>
|
|
||||||
<label class="form-check-label" for="tableShowFreq">Frequency</label>
|
|
||||||
</div>
|
|
||||||
<div class="form-check form-check-inline">
|
|
||||||
<input class="form-check-input storeable-checkbox" type="checkbox" id="tableShowMode" value="tableShowMode" oninput="columnsUpdated();" checked>
|
|
||||||
<label class="form-check-label" for="tableShowMode">Mode</label>
|
|
||||||
</div>
|
|
||||||
<div class="form-check form-check-inline">
|
|
||||||
<input class="form-check-input storeable-checkbox" type="checkbox" id="tableShowComment" value="tableShowComment" oninput="columnsUpdated();" checked>
|
|
||||||
<label class="form-check-label" for="tableShowComment">Comment</label>
|
|
||||||
</div>
|
|
||||||
<div class="form-check form-check-inline">
|
|
||||||
<input class="form-check-input storeable-checkbox" type="checkbox" id="tableShowBearing" value="tableShowBearing" oninput="columnsUpdated();">
|
|
||||||
<label class="form-check-label" for="tableShowBearing">Bearing</label>
|
|
||||||
</div>
|
|
||||||
<div class="form-check form-check-inline">
|
|
||||||
<input class="form-check-input storeable-checkbox" type="checkbox" id="tableShowType" value="tableShowType" oninput="columnsUpdated();" checked>
|
|
||||||
<label class="form-check-label" for="tableShowType">Type</label>
|
|
||||||
</div>
|
|
||||||
<div class="form-check form-check-inline">
|
|
||||||
<input class="form-check-input storeable-checkbox" type="checkbox" id="tableShowRef" value="tableShowRef" oninput="columnsUpdated();" checked>
|
|
||||||
<label class="form-check-label" for="tableShowRef">Ref.</label>
|
|
||||||
</div>
|
|
||||||
<div class="form-check form-check-inline">
|
|
||||||
<input class="form-check-input storeable-checkbox" type="checkbox" id="tableShowDE" value="tableShowDE" oninput="columnsUpdated();" checked>
|
|
||||||
<label class="form-check-label" for="tableShowDE">DE</label>
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div class="col">
|
<div class="row row-cols-1 row-cols-md-4 g-4">
|
||||||
<div class="card">
|
<div class="col">
|
||||||
<div class="card-body">
|
<div class="card">
|
||||||
<h5 class="card-title">Location</h5>
|
<div class="card-body">
|
||||||
<div class="form-group spothole-card-text">
|
<h5 class="card-title">DX Continent</h5>
|
||||||
<label for="userGrid">Your grid:</label>
|
<p id="dx-continent-options" class="card-text spothole-card-text"></p>
|
||||||
<input type="text" class="storeable-text form-control" id="userGrid" placeholder="AA00aa" oninput="userGridUpdated();" style="width: 10em; display: inline-block;">
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="col">
|
||||||
|
<div class="card">
|
||||||
|
<div class="card-body">
|
||||||
|
<h5 class="card-title">DE Continent</h5>
|
||||||
|
<p id="de-continent-options" class="card-text spothole-card-text"></p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="col">
|
||||||
|
<div class="card">
|
||||||
|
<div class="card-body">
|
||||||
|
<h5 class="card-title">Modes</h5>
|
||||||
|
<p id="mode-options" class="card-text spothole-card-text"></p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="col">
|
||||||
|
<div class="card">
|
||||||
|
<div class="card-body">
|
||||||
|
<h5 class="card-title">Sources</h5>
|
||||||
|
<p id="source-options" class="card-text spothole-card-text"></p>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
|
||||||
|
|
||||||
<div id="add-spot-area" class="appearing-panel card mb-3">
|
<div id="display-area" class="appearing-panel card mb-3">
|
||||||
<div class="card-header text-white bg-primary">
|
<div class="card-header text-white bg-primary">
|
||||||
<div class="row">
|
<div class="row">
|
||||||
<div class="col-auto me-auto">
|
<div class="col-auto me-auto">
|
||||||
Add a Spot
|
Display
|
||||||
|
</div>
|
||||||
|
<div class="col-auto d-inline-flex">
|
||||||
|
<button id="close-display-button" type="button" class="btn-close btn-close-white" aria-label="Close" onclick="closeDisplayPanel();"></button>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div class="col-auto d-inline-flex">
|
|
||||||
<button id="close-add-spot-button" type="button" class="btn-close btn-close-white" aria-label="Close" onclick="closeAddSpotPanel();"></button>
|
</div>
|
||||||
|
<div class="card-body">
|
||||||
|
<div id="display-container" class="row row-cols-1 row-cols-md-4 g-4">
|
||||||
|
<div class="col">
|
||||||
|
<div class="card">
|
||||||
|
<div class="card-body">
|
||||||
|
<h5 class="card-title">Time Zone</h5>
|
||||||
|
<p class="card-text spothole-card-text"> Use
|
||||||
|
<select id="timeZone" class="storeable-select form-select ms-2 me-2 d-inline-block" oninput="timeZoneUpdated();" style="width: 8em; display: inline-block;">
|
||||||
|
<option value="UTC" selected>UTC</option>
|
||||||
|
<option value="local">Local time</option>
|
||||||
|
</select>
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="col">
|
||||||
|
<div class="card">
|
||||||
|
<div class="card-body">
|
||||||
|
<h5 class="card-title">Number of Spots</h5>
|
||||||
|
<p class="card-text spothole-card-text">Show up to
|
||||||
|
<select id="spots-to-fetch" class="storeable-select form-select ms-2 me-2 d-inline-block" oninput="filtersUpdated();" style="width: 5em; display: inline-block;">
|
||||||
|
<option value="10">10</option>
|
||||||
|
<option value="25">25</option>
|
||||||
|
<option value="50" selected>50</option>
|
||||||
|
<option value="100">100</option>
|
||||||
|
</select>
|
||||||
|
spots
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="col">
|
||||||
|
<div class="card">
|
||||||
|
<div class="card-body">
|
||||||
|
<h5 class="card-title">Table Columns</h5>
|
||||||
|
<div class="form-group">
|
||||||
|
<div class="form-check form-check-inline">
|
||||||
|
<input class="form-check-input storeable-checkbox" type="checkbox" id="tableShowTime" value="tableShowTime" oninput="columnsUpdated();" checked>
|
||||||
|
<label class="form-check-label" for="tableShowTime">Time</label>
|
||||||
|
</div>
|
||||||
|
<div class="form-check form-check-inline">
|
||||||
|
<input class="form-check-input storeable-checkbox" type="checkbox" id="tableShowDX" value="tableShowDX" oninput="columnsUpdated();" checked>
|
||||||
|
<label class="form-check-label" for="tableShowDX">DX</label>
|
||||||
|
</div>
|
||||||
|
<div class="form-check form-check-inline">
|
||||||
|
<input class="form-check-input storeable-checkbox" type="checkbox" id="tableShowFreq" value="tableShowFreq" oninput="columnsUpdated();" checked>
|
||||||
|
<label class="form-check-label" for="tableShowFreq">Frequency</label>
|
||||||
|
</div>
|
||||||
|
<div class="form-check form-check-inline">
|
||||||
|
<input class="form-check-input storeable-checkbox" type="checkbox" id="tableShowMode" value="tableShowMode" oninput="columnsUpdated();" checked>
|
||||||
|
<label class="form-check-label" for="tableShowMode">Mode</label>
|
||||||
|
</div>
|
||||||
|
<div class="form-check form-check-inline">
|
||||||
|
<input class="form-check-input storeable-checkbox" type="checkbox" id="tableShowComment" value="tableShowComment" oninput="columnsUpdated();" checked>
|
||||||
|
<label class="form-check-label" for="tableShowComment">Comment</label>
|
||||||
|
</div>
|
||||||
|
<div class="form-check form-check-inline">
|
||||||
|
<input class="form-check-input storeable-checkbox" type="checkbox" id="tableShowBearing" value="tableShowBearing" oninput="columnsUpdated();">
|
||||||
|
<label class="form-check-label" for="tableShowBearing">Bearing</label>
|
||||||
|
</div>
|
||||||
|
<div class="form-check form-check-inline">
|
||||||
|
<input class="form-check-input storeable-checkbox" type="checkbox" id="tableShowSource" value="tableShowSource" oninput="columnsUpdated();" checked>
|
||||||
|
<label class="form-check-label" for="tableShowSource">Source</label>
|
||||||
|
</div>
|
||||||
|
<div class="form-check form-check-inline">
|
||||||
|
<input class="form-check-input storeable-checkbox" type="checkbox" id="tableShowRef" value="tableShowRef" oninput="columnsUpdated();" checked>
|
||||||
|
<label class="form-check-label" for="tableShowRef">Ref.</label>
|
||||||
|
</div>
|
||||||
|
<div class="form-check form-check-inline">
|
||||||
|
<input class="form-check-input storeable-checkbox" type="checkbox" id="tableShowDE" value="tableShowDE" oninput="columnsUpdated();" checked>
|
||||||
|
<label class="form-check-label" for="tableShowDE">DE</label>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="col">
|
||||||
|
<div class="card">
|
||||||
|
<div class="card-body">
|
||||||
|
<h5 class="card-title">Location</h5>
|
||||||
|
<div class="form-group spothole-card-text">
|
||||||
|
<label for="userGrid">Your grid:</label>
|
||||||
|
<input type="text" class="storeable-text form-control" id="userGrid" placeholder="AA00aa" oninput="userGridUpdated();" style="width: 10em; display: inline-block;">
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
</div>
|
</div>
|
||||||
<div class="card-body">
|
|
||||||
<form class="row g-2">
|
|
||||||
<div class="col-auto">
|
|
||||||
<label for="add-spot-dx-call" class="form-label">DX Call</label>
|
|
||||||
<input type="text" class="form-control" id="add-spot-dx-call" placeholder="N0CALL" style="max-width: 8em;">
|
|
||||||
</div>
|
|
||||||
<div class="col-auto">
|
|
||||||
<label for="add-spot-freq" class="form-label">Frequency (kHz)</label>
|
|
||||||
<input type="text" class="form-control" id="add-spot-freq" placeholder="14100" style="max-width: 8em;">
|
|
||||||
</div>
|
|
||||||
<div class="col-auto">
|
|
||||||
<label for="add-spot-mode" class="form-label">Mode</label>
|
|
||||||
<input type="text" class="form-control" id="add-spot-mode" placeholder="SSB" style="max-width: 6em;">
|
|
||||||
</div>
|
|
||||||
<div class="col-auto">
|
|
||||||
<label for="add-spot-comment" class="form-label">Comment</label>
|
|
||||||
<input type="text" class="form-control" id="add-spot-comment" placeholder="59 TNX QSO 73" style="max-width: 12em;">
|
|
||||||
</div>
|
|
||||||
<div class="col-auto">
|
|
||||||
<label for="add-spot-de-call" class="form-label">Your Call</label>
|
|
||||||
<input type="text" class="form-control" id="add-spot-de-call" placeholder="N0CALL" style="max-width: 8em;">
|
|
||||||
</div>
|
|
||||||
<div class="col-auto">
|
|
||||||
<button type="button" class="btn btn-primary" style="margin-top: 2em;" onclick="addSpot();">Spot</button>
|
|
||||||
<span id="post-spot-result-good"></span>
|
|
||||||
</div>
|
|
||||||
</form>
|
|
||||||
|
|
||||||
<div id="post-spot-result-bad"></div>
|
<div id="add-spot-area" class="appearing-panel card mb-3">
|
||||||
|
<div class="card-header text-white bg-primary">
|
||||||
|
<div class="row">
|
||||||
|
<div class="col-auto me-auto">
|
||||||
|
Add a Spot
|
||||||
|
</div>
|
||||||
|
<div class="col-auto d-inline-flex">
|
||||||
|
<button id="close-add-spot-button" type="button" class="btn-close btn-close-white" aria-label="Close" onclick="closeAddSpotPanel();"></button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
<div class="alert alert-warning alert-dismissible fade show mb-0 mt-4" role="alert">
|
</div>
|
||||||
Please note that spots added to Spothole are not currently sent "upstream" to DX clusters or xOTA spotting sites.
|
<div class="card-body">
|
||||||
<button type="button" class="btn-close" data-bs-dismiss="alert" aria-label="Close"></button>
|
<form class="row g-2">
|
||||||
|
<div class="col-auto">
|
||||||
|
<label for="add-spot-dx-call" class="form-label">DX Call</label>
|
||||||
|
<input type="text" class="form-control" id="add-spot-dx-call" placeholder="N0CALL" style="max-width: 8em;">
|
||||||
|
</div>
|
||||||
|
<div class="col-auto">
|
||||||
|
<label for="add-spot-freq" class="form-label">Frequency (kHz)</label>
|
||||||
|
<input type="text" class="form-control" id="add-spot-freq" placeholder="14100" style="max-width: 8em;">
|
||||||
|
</div>
|
||||||
|
<div class="col-auto">
|
||||||
|
<label for="add-spot-mode" class="form-label">Mode</label>
|
||||||
|
<input type="text" class="form-control" id="add-spot-mode" placeholder="SSB" style="max-width: 6em;">
|
||||||
|
</div>
|
||||||
|
<div class="col-auto">
|
||||||
|
<label for="add-spot-comment" class="form-label">Comment</label>
|
||||||
|
<input type="text" class="form-control" id="add-spot-comment" placeholder="59 TNX QSO 73" style="max-width: 12em;">
|
||||||
|
</div>
|
||||||
|
<div class="col-auto">
|
||||||
|
<label for="add-spot-de-call" class="form-label">Your Call</label>
|
||||||
|
<input type="text" class="form-control" id="add-spot-de-call" placeholder="N0CALL" style="max-width: 8em;">
|
||||||
|
</div>
|
||||||
|
<div class="col-auto">
|
||||||
|
<button type="button" class="btn btn-primary" style="margin-top: 2em;" onclick="addSpot();">Spot</button>
|
||||||
|
<span id="post-spot-result-good"></span>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
|
||||||
|
<div id="post-spot-result-bad"></div>
|
||||||
|
|
||||||
|
<div class="alert alert-warning alert-dismissible fade show mb-0 mt-4" role="alert">
|
||||||
|
Please note that spots added to Spothole are not currently sent "upstream" to DX clusters or xOTA spotting sites.
|
||||||
|
<button type="button" class="btn-close" data-bs-dismiss="alert" aria-label="Close"></button>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<div id="table-container"></div>
|
||||||
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div id="table-container"></div>
|
|
||||||
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<script src="/js/common.js"></script>
|
<script src="/js/common.js"></script>
|
||||||
|
|||||||
@@ -1,6 +1,8 @@
|
|||||||
% rebase('webpage_base.tpl')
|
% rebase('webpage_base.tpl')
|
||||||
|
|
||||||
<div id="status-container" class="row row-cols-1 row-cols-md-4 g-4 mt-4"></div>
|
<div class="container main-container">
|
||||||
|
<div id="status-container" class="row row-cols-1 row-cols-md-4 g-4 mt-4"></div>
|
||||||
|
</div>
|
||||||
|
|
||||||
<script src="/js/common.js"></script>
|
<script src="/js/common.js"></script>
|
||||||
<script src="/js/status.js"></script>
|
<script src="/js/status.js"></script>
|
||||||
|
|||||||
@@ -65,7 +65,7 @@ paths:
|
|||||||
- APRS-IS
|
- APRS-IS
|
||||||
- name: sig
|
- name: sig
|
||||||
in: query
|
in: query
|
||||||
description: "Limit the spots to only ones from one or more Special Interest Groups provided as an argument. To select more than one SIG, supply a comma-separated list."
|
description: "Limit the spots to only ones from one or more Special Interest Groups. To select more than one SIG, supply a comma-separated list."
|
||||||
required: false
|
required: false
|
||||||
schema:
|
schema:
|
||||||
type: string
|
type: string
|
||||||
@@ -76,27 +76,6 @@ paths:
|
|||||||
- WWBOTA
|
- WWBOTA
|
||||||
- GMA
|
- GMA
|
||||||
- HEMA
|
- HEMA
|
||||||
- WCA
|
|
||||||
- MOTA
|
|
||||||
- SiOTA
|
|
||||||
- ARLHS
|
|
||||||
- ILLW
|
|
||||||
- ZLOTA
|
|
||||||
- IOTA
|
|
||||||
- name: needs_sig
|
|
||||||
in: query
|
|
||||||
description: "Limit the spots to only ones with a Special Interest Group such as POTA. Because supplying all known SIGs as a `sigs` parameter is unwieldy, and leaving `sigs` blank will also return spots with *no* SIG, this parameter can be set true to return only spots with a SIG, regardless of what it is, so long as it's not blank. This is what Field Spotter uses to exclude generic cluster spots and only retrieve xOTA things."
|
|
||||||
required: false
|
|
||||||
schema:
|
|
||||||
type: boolean
|
|
||||||
default: false
|
|
||||||
- name: needs_sig_ref
|
|
||||||
in: query
|
|
||||||
description: "Limit the spots to only ones which have at least one reference (e.g. a park reference) for Special Interest Groups such as POTA."
|
|
||||||
required: false
|
|
||||||
schema:
|
|
||||||
type: boolean
|
|
||||||
default: false
|
|
||||||
- name: band
|
- name: band
|
||||||
in: query
|
in: query
|
||||||
description: "Limit the spots to only ones from one or more bands. To select more than one band, supply a comma-separated list."
|
description: "Limit the spots to only ones from one or more bands. To select more than one band, supply a comma-separated list."
|
||||||
@@ -189,33 +168,6 @@ paths:
|
|||||||
- AF
|
- AF
|
||||||
- OC
|
- OC
|
||||||
- AN
|
- AN
|
||||||
- name: dedupe
|
|
||||||
in: query
|
|
||||||
description: "\"De-duplicate\" the spots, returning only the latest spot for any given callsign."
|
|
||||||
required: false
|
|
||||||
schema:
|
|
||||||
type: boolean
|
|
||||||
default: false
|
|
||||||
- name: comment_includes
|
|
||||||
in: query
|
|
||||||
description: "Return only spots where the comment includes the provided string (case-insensitive)."
|
|
||||||
required: false
|
|
||||||
schema:
|
|
||||||
type: string
|
|
||||||
- name: needs_good_location
|
|
||||||
in: query
|
|
||||||
description: "Return only spots with a 'good' location. (See the spot `dx_location_good` parameter for details. Useful for map-based clients, to avoid spots with 'bad' locations e.g. loads of cluster spots ending up in the centre of the DXCC entitity.)"
|
|
||||||
required: false
|
|
||||||
schema:
|
|
||||||
type: boolean
|
|
||||||
default: false
|
|
||||||
- name: allow_qrt
|
|
||||||
in: query
|
|
||||||
description: Allow spots that are known to be QRT to be returned.
|
|
||||||
required: false
|
|
||||||
schema:
|
|
||||||
type: boolean
|
|
||||||
default: true
|
|
||||||
responses:
|
responses:
|
||||||
'200':
|
'200':
|
||||||
description: Success
|
description: Success
|
||||||
@@ -289,13 +241,6 @@ paths:
|
|||||||
- WWBOTA
|
- WWBOTA
|
||||||
- GMA
|
- GMA
|
||||||
- HEMA
|
- HEMA
|
||||||
- WCA
|
|
||||||
- MOTA
|
|
||||||
- SiOTA
|
|
||||||
- ARLHS
|
|
||||||
- ILLW
|
|
||||||
- ZLOTA
|
|
||||||
- IOTA
|
|
||||||
- name: dx_continent
|
- name: dx_continent
|
||||||
in: query
|
in: query
|
||||||
description: "Limit the alerts to only ones where the DX (the operator being spotted) is on the given continent(s). To select more than one continent, supply a comma-separated list."
|
description: "Limit the alerts to only ones where the DX (the operator being spotted) is on the given continent(s). To select more than one continent, supply a comma-separated list."
|
||||||
@@ -434,7 +379,8 @@ paths:
|
|||||||
type: array
|
type: array
|
||||||
description: An array of all the supported Special Interest Groups.
|
description: An array of all the supported Special Interest Groups.
|
||||||
items:
|
items:
|
||||||
$ref: '#/components/schemas/SIG'
|
type: string
|
||||||
|
example: "POTA"
|
||||||
sources:
|
sources:
|
||||||
type: array
|
type: array
|
||||||
description: An array of all the supported data sources.
|
description: An array of all the supported data sources.
|
||||||
@@ -572,14 +518,13 @@ components:
|
|||||||
description: Where we got the DX location (grid/latitude/longitude) from. If this was from the spot itself, it's likely quite accurate, but if we had to fall back to QRZ lookup, or even a location based on the DXCC itself, it will be a lot less accurate.
|
description: Where we got the DX location (grid/latitude/longitude) from. If this was from the spot itself, it's likely quite accurate, but if we had to fall back to QRZ lookup, or even a location based on the DXCC itself, it will be a lot less accurate.
|
||||||
enum:
|
enum:
|
||||||
- SPOT
|
- SPOT
|
||||||
- "WAB/WAI GRID"
|
|
||||||
- QRZ
|
- QRZ
|
||||||
- DXCC
|
- DXCC
|
||||||
- NONE
|
- NONE
|
||||||
example: SPOT
|
example: SPOT
|
||||||
dx_location_good:
|
dx_location_good:
|
||||||
type: boolean
|
type: boolean
|
||||||
description: Does the software think the location is good enough to put a marker on a map? This is true if the source is "SPOT" or "WAB/WAI GRID", or alternatively if the source is "QRZ" and the callsign doesn't have a slash in it (i.e. operator likely at home).
|
description: Does the software think the location is good enough to put a marker on a map? This is true if the source is "SPOT", or alternatively if the source is "QRZ" and the callsign doesn't have a slash in it (i.e. operator likely at home).
|
||||||
example: true
|
example: true
|
||||||
de_call:
|
de_call:
|
||||||
type: string
|
type: string
|
||||||
@@ -722,13 +667,6 @@ components:
|
|||||||
- WWBOTA
|
- WWBOTA
|
||||||
- GMA
|
- GMA
|
||||||
- HEMA
|
- HEMA
|
||||||
- WCA
|
|
||||||
- MOTA
|
|
||||||
- SiOTA
|
|
||||||
- ARLHS
|
|
||||||
- ILLW
|
|
||||||
- ZLOTA
|
|
||||||
- IOTA
|
|
||||||
example: POTA
|
example: POTA
|
||||||
sig_refs:
|
sig_refs:
|
||||||
type: array
|
type: array
|
||||||
@@ -742,12 +680,6 @@ components:
|
|||||||
type: string
|
type: string
|
||||||
description: SIG reference names
|
description: SIG reference names
|
||||||
example: Null Country Park
|
example: Null Country Park
|
||||||
sig_refs_urls:
|
|
||||||
type: array
|
|
||||||
items:
|
|
||||||
type: string
|
|
||||||
description: SIG reference URLs, which the user can look up for more information
|
|
||||||
example: "https://pota.app/#/park/GB-0001"
|
|
||||||
activation_score:
|
activation_score:
|
||||||
type: integer
|
type: integer
|
||||||
description: Activation score. SOTA only
|
description: Activation score. SOTA only
|
||||||
@@ -756,14 +688,6 @@ components:
|
|||||||
type: string
|
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.
|
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
|
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.
|
||||||
@@ -882,13 +806,6 @@ components:
|
|||||||
- WWBOTA
|
- WWBOTA
|
||||||
- GMA
|
- GMA
|
||||||
- HEMA
|
- HEMA
|
||||||
- WCA
|
|
||||||
- MOTA
|
|
||||||
- SiOTA
|
|
||||||
- ARLHS
|
|
||||||
- ILLW
|
|
||||||
- ZLOTA
|
|
||||||
- IOTA
|
|
||||||
example: POTA
|
example: POTA
|
||||||
sig_refs:
|
sig_refs:
|
||||||
type: array
|
type: array
|
||||||
@@ -910,6 +827,14 @@ components:
|
|||||||
type: string
|
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.
|
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
|
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"
|
||||||
source:
|
source:
|
||||||
type: string
|
type: string
|
||||||
description: Where we got the alert from.
|
description: Where we got the alert from.
|
||||||
@@ -996,24 +921,4 @@ components:
|
|||||||
contrast_color:
|
contrast_color:
|
||||||
type: string
|
type: string
|
||||||
description: Black or white, whichever provides the best contrast against the band colour.
|
description: Black or white, whichever provides the best contrast against the band colour.
|
||||||
example: white
|
example: white
|
||||||
|
|
||||||
SIG:
|
|
||||||
type: object
|
|
||||||
properties:
|
|
||||||
name:
|
|
||||||
type: string
|
|
||||||
description: The abbreviated name of the SIG
|
|
||||||
example: POTA
|
|
||||||
description:
|
|
||||||
type: string
|
|
||||||
description: The full name of the SIG
|
|
||||||
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:
|
|
||||||
type: string
|
|
||||||
description: Regex that matches this SIG's reference IDs. Generally for Spothole's own internal use, clients probably won't need this.
|
|
||||||
example: "[A-Z]{2}\\-\\d+"
|
|
||||||
@@ -14,7 +14,7 @@
|
|||||||
|
|
||||||
/* GENERAL PAGE LAYOUT */
|
/* GENERAL PAGE LAYOUT */
|
||||||
|
|
||||||
div.container {
|
div.main-container {
|
||||||
display:grid;
|
display:grid;
|
||||||
grid-template-rows:auto 1fr auto;
|
grid-template-rows:auto 1fr auto;
|
||||||
grid-template-columns:100%;
|
grid-template-columns:100%;
|
||||||
@@ -92,13 +92,10 @@ span.icon-wrapper {
|
|||||||
}
|
}
|
||||||
|
|
||||||
span.freq-mhz {
|
span.freq-mhz {
|
||||||
font-weight: bold;
|
|
||||||
}
|
|
||||||
|
|
||||||
span.freq-mhz-pad {
|
|
||||||
display: inline-block;
|
display: inline-block;
|
||||||
min-width: 1.7em;
|
min-width: 1.7em;
|
||||||
text-align: right;
|
text-align: right;
|
||||||
|
font-weight: bold;
|
||||||
}
|
}
|
||||||
|
|
||||||
span.freq-khz {
|
span.freq-khz {
|
||||||
@@ -120,10 +117,6 @@ a.dx-link {
|
|||||||
text-decoration: none;
|
text-decoration: none;
|
||||||
font-weight: bold;
|
font-weight: bold;
|
||||||
}
|
}
|
||||||
a.sig-ref-link {
|
|
||||||
color: var(--bs-emphasis-color);
|
|
||||||
text-decoration: none;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* QRT/faded styles */
|
/* QRT/faded styles */
|
||||||
tr.table-faded td {
|
tr.table-faded td {
|
||||||
@@ -149,99 +142,10 @@ div#map {
|
|||||||
font-family: var(--bs-body-font-family) !important;
|
font-family: var(--bs-body-font-family) !important;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
a.leaflet-popup-callsign-link {
|
||||||
/* BANDS PANEL */
|
color: black;
|
||||||
|
|
||||||
div#bands-container {
|
|
||||||
min-height: 64em;
|
|
||||||
margin: 0;
|
|
||||||
padding: 0;
|
|
||||||
overflow-x: auto;
|
|
||||||
white-space: nowrap;
|
|
||||||
overscroll-behavior-x: none;
|
|
||||||
}
|
|
||||||
|
|
||||||
#bands-table {
|
|
||||||
min-width: 100%;
|
|
||||||
}
|
|
||||||
|
|
||||||
#bands-table th {
|
|
||||||
width: 20%;
|
|
||||||
max-height: 40px;
|
|
||||||
min-width: 12em;
|
|
||||||
padding: 0.5em;
|
|
||||||
text-align: center;
|
|
||||||
font-weight: bold;
|
font-weight: bold;
|
||||||
}
|
text-decoration: none;
|
||||||
|
|
||||||
#bands-table td {
|
|
||||||
width: 20%;
|
|
||||||
min-width: 12em;
|
|
||||||
height: 62em;
|
|
||||||
}
|
|
||||||
|
|
||||||
div.band-container {
|
|
||||||
height: 62em;
|
|
||||||
width: 20%;
|
|
||||||
min-width: 12em;
|
|
||||||
position: relative;
|
|
||||||
}
|
|
||||||
|
|
||||||
div.band-markers {
|
|
||||||
width: 100%;
|
|
||||||
height: 100%;
|
|
||||||
position: absolute;
|
|
||||||
top: 0;
|
|
||||||
left: 0;
|
|
||||||
z-index: 13;
|
|
||||||
border-left: 2px dotted black;
|
|
||||||
}
|
|
||||||
|
|
||||||
div.band-spots {
|
|
||||||
width: 100%;
|
|
||||||
height: 100%;
|
|
||||||
position: absolute;
|
|
||||||
top: 0;
|
|
||||||
left: 0;
|
|
||||||
z-index: 15;
|
|
||||||
}
|
|
||||||
|
|
||||||
canvas.band-lines-canvas {
|
|
||||||
width: 5em;
|
|
||||||
height: 100%;
|
|
||||||
position: absolute;
|
|
||||||
top: 0;
|
|
||||||
left: 0;
|
|
||||||
z-index: 11;
|
|
||||||
}
|
|
||||||
|
|
||||||
div.band-spot {
|
|
||||||
position: absolute;
|
|
||||||
left: 5em;
|
|
||||||
padding: 0 0.25em;
|
|
||||||
background-color: white;
|
|
||||||
border-radius: 3px;
|
|
||||||
cursor: default;
|
|
||||||
}
|
|
||||||
|
|
||||||
div.band-spot:hover {
|
|
||||||
z-index: 999;
|
|
||||||
}
|
|
||||||
|
|
||||||
div.band-spot span.band-spot-call {
|
|
||||||
display: inline;
|
|
||||||
}
|
|
||||||
|
|
||||||
div.band-spot:hover span.band-spot-call {
|
|
||||||
display: none;
|
|
||||||
}
|
|
||||||
|
|
||||||
div.band-spot span.band-spot-info {
|
|
||||||
display: none;
|
|
||||||
}
|
|
||||||
|
|
||||||
div.band-spot:hover span.band-spot-info {
|
|
||||||
display: inline;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
@@ -251,9 +155,9 @@ div.band-spot:hover span.band-spot-info {
|
|||||||
.hideonmobile {
|
.hideonmobile {
|
||||||
display: none !important;
|
display: none !important;
|
||||||
}
|
}
|
||||||
div#map, div#table-container, div#bands-container {
|
.mobile-no-gutters {
|
||||||
margin-left: -1em;
|
padding-left: 0 !important;
|
||||||
margin-right: -1em;
|
padding-right: 0 !important;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -124,12 +124,16 @@ function addAlertRowsToTable(tbody, alerts) {
|
|||||||
var showRef = $("#tableShowRef")[0].checked;
|
var showRef = $("#tableShowRef")[0].checked;
|
||||||
|
|
||||||
// Get times for the alert, and convert to local time if necessary.
|
// Get times for the alert, and convert to local time if necessary.
|
||||||
var start_time_utc = moment.unix(a["start_time"]).utc();
|
var start_time_unix = moment.unix(a["start_time"]);
|
||||||
var start_time_local = start_time_utc.clone().local();
|
var start_time = start_time_unix.utc();
|
||||||
start_time = useLocalTime ? start_time_local : start_time_utc;
|
if (useLocalTime) {
|
||||||
var end_time_utc = moment.unix(a["end_time"]).utc();
|
start_time = start_time.local();
|
||||||
var end_time_local = end_time_utc.clone().local();
|
}
|
||||||
end_time = useLocalTime ? end_time_local : end_time_utc;
|
var end_time_unix = moment.unix(a["end_time"]);
|
||||||
|
var end_time = end_time_unix.utc();
|
||||||
|
if (useLocalTime) {
|
||||||
|
end_time = end_time.local();
|
||||||
|
}
|
||||||
|
|
||||||
// Format the times for display. Start time is displayed as e.g. 7 Oct 12:34 unless the time is in a
|
// Format the times for display. Start time is displayed as e.g. 7 Oct 12:34 unless the time is in a
|
||||||
// different year to the current year, in which case the year is inserted between month and hour.
|
// different year to the current year, in which case the year is inserted between month and hour.
|
||||||
@@ -139,8 +143,8 @@ function addAlertRowsToTable(tbody, alerts) {
|
|||||||
// Overriding all of that, if the start time is 00:00 and the end time is 23:59 when considered in UTC, the
|
// Overriding all of that, if the start time is 00:00 and the end time is 23:59 when considered in UTC, the
|
||||||
// hours and minutes are stripped out from the display, as we assume the server is just giving us full days.
|
// hours and minutes are stripped out from the display, as we assume the server is just giving us full days.
|
||||||
// Finally, if there is no end date set, "---" is displayed.
|
// Finally, if there is no end date set, "---" is displayed.
|
||||||
var whole_days = start_time_utc.format("HH:mm") == "00:00" &&
|
var whole_days = start_time_unix.utc().format("HH:mm") == "00:00" &&
|
||||||
(end_time_utc != null || end_time_utc > 0 || end_time_utc.format("HH:mm") == "23:59");
|
(end_time_unix != null || end_time_unix > 0 || end_time_unix.utc().format("HH:mm") == "23:59");
|
||||||
var hours_minutes_format = whole_days ? "" : " HH:mm";
|
var hours_minutes_format = whole_days ? "" : " HH:mm";
|
||||||
var start_time_formatted = start_time.format("D MMM" + hours_minutes_format);
|
var start_time_formatted = start_time.format("D MMM" + hours_minutes_format);
|
||||||
if (start_time.format("YYYY") != moment().format("YYYY")) {
|
if (start_time.format("YYYY") != moment().format("YYYY")) {
|
||||||
@@ -149,13 +153,11 @@ function addAlertRowsToTable(tbody, alerts) {
|
|||||||
start_time_formatted = start_time.format("[Today]" + hours_minutes_format);
|
start_time_formatted = start_time.format("[Today]" + hours_minutes_format);
|
||||||
}
|
}
|
||||||
var end_time_formatted = "---";
|
var end_time_formatted = "---";
|
||||||
if (end_time_utc != null && end_time_utc > 0 && end_time != null) {
|
if (end_time_unix != null && end_time_unix > 0 && end_time != null) {
|
||||||
var end_time_formatted = whole_days ? start_time_formatted : end_time.format("HH:mm");
|
var end_time_formatted = whole_days ? start_time_formatted : end_time.format("HH:mm");
|
||||||
if (end_time.format("D MMM") != start_time.format("D MMM")) {
|
if (end_time.format("D MMM") != start_time.format("D MMM")) {
|
||||||
if (end_time.format("YYYY") != moment().format("YYYY")) {
|
if (end_time.format("YYYY") != moment().format("YYYY")) {
|
||||||
end_time_formatted = end_time.format("D MMM YYYY" + hours_minutes_format);
|
end_time_formatted = end_time.format("D MMM YYYY" + hours_minutes_format);
|
||||||
} else if (useLocalTime && end_time.format("D MMM YYYY") == moment().format("D MMM YYYY")) {
|
|
||||||
end_time_formatted = end_time.format("[Today]" + hours_minutes_format);
|
|
||||||
} else {
|
} else {
|
||||||
end_time_formatted = end_time.format("D MMM" + hours_minutes_format);
|
end_time_formatted = end_time.format("D MMM" + hours_minutes_format);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,286 +0,0 @@
|
|||||||
// A couple of constants that must match what's in CSS. We need to know them before the content actually renders, so we
|
|
||||||
// can't just ask the elements themselves for their dimensions.
|
|
||||||
BAND_COLUMN_HEIGHT_EM = 62;
|
|
||||||
BAND_COLUMN_CANVAS_WIDTH_EM = 4;
|
|
||||||
BAND_COLUMN_FONT_SIZE = 16;
|
|
||||||
BAND_COLUMN_HEIGHT_PX = BAND_COLUMN_HEIGHT_EM * BAND_COLUMN_FONT_SIZE;
|
|
||||||
BAND_COLUMN_CANVAS_WIDTH_PX = BAND_COLUMN_CANVAS_WIDTH_EM * BAND_COLUMN_FONT_SIZE;
|
|
||||||
BAND_COLUMN_SPOT_DIV_HEIGHT_PX = BAND_COLUMN_FONT_SIZE * 1.6;
|
|
||||||
|
|
||||||
// Load spots and populate the bands display.
|
|
||||||
function loadSpots() {
|
|
||||||
$.getJSON('/api/v1/spots' + buildQueryString(), function(jsonData) {
|
|
||||||
// Store last updated time
|
|
||||||
lastUpdateTime = moment.utc();
|
|
||||||
updateRefreshDisplay();
|
|
||||||
// Store data
|
|
||||||
spots = jsonData;
|
|
||||||
// Update bands display
|
|
||||||
updateBands();
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
// Build a query string for the API, based on the filters that the user has selected.
|
|
||||||
function buildQueryString() {
|
|
||||||
var str = "?";
|
|
||||||
["dx_continent", "de_continent", "mode_type", "source", "band"].forEach(fn => {
|
|
||||||
if (!allFilterOptionsSelected(fn)) {
|
|
||||||
str = str + getQueryStringFor(fn) + "&";
|
|
||||||
}
|
|
||||||
});
|
|
||||||
str = str + "max_age=" + $("#max-spot-age option:selected").val();
|
|
||||||
// Additional filters for the bands view: No dupes, no QRT
|
|
||||||
str = str + "&dedupe=true&allow_qrt=false";
|
|
||||||
return str;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Update the bands display
|
|
||||||
function updateBands() {
|
|
||||||
// Stop here if nothing to display
|
|
||||||
var bandsContainer = $("#bands-container");
|
|
||||||
if (spots.length === 0) {
|
|
||||||
bandsContainer.html("<div class='alert alert-danger' role='alert'>No spots match your filters.</div>");
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Do some harsher de-duping. Because we only display callsign, frequency and mode here, the previous
|
|
||||||
// de-duplication could have let some through that don't look like dupes on the map, but would do here.
|
|
||||||
// Typically that's a person activating two programs at the same time, e.g. POTA & WWFF.
|
|
||||||
spotList = removeDuplicatesForBandPanel(spots);
|
|
||||||
|
|
||||||
// Convert to a map of band names to the spots on that band. Bands with no
|
|
||||||
// spots in view will not be present.
|
|
||||||
const bandToSpots = new Map();
|
|
||||||
options["bands"].forEach(function (band) {
|
|
||||||
const matchingSpots = spotList.filter(function (s) {
|
|
||||||
return s.band === band.name;
|
|
||||||
});
|
|
||||||
if (matchingSpots.length > 0) {
|
|
||||||
bandToSpots.set(band.name, matchingSpots);
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
// Track if any columns end up taller than expected, so we can resize the container and avoid vertical scroll.
|
|
||||||
var maxHeightBand = 0;
|
|
||||||
|
|
||||||
// Build up table content for each band
|
|
||||||
var table = $('<table id="bands-table">').append('<thead><tr></tr></thead><tbody><tr></tr></tbody>');
|
|
||||||
bandToSpots.forEach(function (spotList, bandName) {
|
|
||||||
// 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>`);
|
|
||||||
|
|
||||||
// Get the band data to fetch start and end frequencies
|
|
||||||
let band = options["bands"].filter(function (b) {
|
|
||||||
return b.name === bandName;
|
|
||||||
})[0];
|
|
||||||
|
|
||||||
// Print the frequency band markers. This is 41 steps to divide the band evenly into 40 markers. One in every
|
|
||||||
// four will show the actual frequency, the others will just be dashes.
|
|
||||||
bandMarkersDiv = $('<div class="band-markers">');
|
|
||||||
const freqStep = (band.end_freq - band.start_freq) / 40.0;
|
|
||||||
for (let i = 0; i <= 40; i++) {
|
|
||||||
if (i % 4 === 0) {
|
|
||||||
bandMarkersDiv.append("—" + ((band.start_freq + i * freqStep)/1000000).toFixed(3) + "<br/>");
|
|
||||||
} else if (i % 4 === 2) {
|
|
||||||
bandMarkersDiv.append("–<br/>");
|
|
||||||
} else {
|
|
||||||
bandMarkersDiv.append("-<br/>");
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Prepare the spots list
|
|
||||||
var bandSpotsDiv = $("<div class='band-spots'>");
|
|
||||||
var lastSpotPxDownBand = -999;
|
|
||||||
// Sort by frequency so have a consistent order in which to plan where they will appear on the band div.
|
|
||||||
spotList.sort(function(a, b) { return a.freq - b.freq; });
|
|
||||||
// First calculate how we should be displaying the spots. There are three "modes" to try to place them in a
|
|
||||||
// visually appealing way:
|
|
||||||
// 1) Spaced normally, not going over the end of the band, so we populate them forwards.
|
|
||||||
// 2) Would go over the end, but the spots don't fill the band, so we populate them backwards.
|
|
||||||
// 3) Spots totally fill the band (or more), so we space them evenly starting at the top.
|
|
||||||
// In each case, we don't add anything to the DOM yet, we just calculate "pxDownBandLabel" (how far the *top* of
|
|
||||||
// the label is from the top of the div) and add that as a property to the spot for later use.
|
|
||||||
if (spotList.length >= BAND_COLUMN_HEIGHT_PX / BAND_COLUMN_SPOT_DIV_HEIGHT_PX) {
|
|
||||||
// Mode 3.
|
|
||||||
// Just lay out all spots simply, starting at 0px offset and working down with each one touching.
|
|
||||||
lastSpotPxDownBand = 0 - BAND_COLUMN_SPOT_DIV_HEIGHT_PX;
|
|
||||||
spotList.forEach(s => {
|
|
||||||
lastSpotPxDownBand = lastSpotPxDownBand + BAND_COLUMN_SPOT_DIV_HEIGHT_PX;
|
|
||||||
s["pxDownBandLabel"] = lastSpotPxDownBand;
|
|
||||||
});
|
|
||||||
} else {
|
|
||||||
// Mode 1 or 2. Run through adding things to the list forwards as a test.
|
|
||||||
spotList.forEach(s => {
|
|
||||||
// Work out how far down the div to draw it
|
|
||||||
var percentDownBand = (s.freq - band.start_freq) / (band.end_freq - band.start_freq) * 0.97; // not 100% due to fudge, the first and last dashes are not exactly at the top and bottom of the div as some space is needed for text
|
|
||||||
var pxDownBand = percentDownBand * BAND_COLUMN_HEIGHT_PX;
|
|
||||||
if (pxDownBand < lastSpotPxDownBand + BAND_COLUMN_SPOT_DIV_HEIGHT_PX) {
|
|
||||||
pxDownBand = lastSpotPxDownBand + BAND_COLUMN_SPOT_DIV_HEIGHT_PX; // Prevent overlap
|
|
||||||
}
|
|
||||||
s["pxDownBandLabel"] = pxDownBand;
|
|
||||||
lastSpotPxDownBand = pxDownBand;
|
|
||||||
});
|
|
||||||
|
|
||||||
// Work out if we overflowed the end.
|
|
||||||
if (lastSpotPxDownBand <= BAND_COLUMN_HEIGHT_PX) {
|
|
||||||
// Mode 1. Current positions are fine and there's nothing to do.
|
|
||||||
} else {
|
|
||||||
// Mode 2. Repeat the process but backwards, starting at the end and working upwards.
|
|
||||||
lastSpotPxDownBand = 999999;
|
|
||||||
spotList.reverse().forEach(s => {
|
|
||||||
// Work out how far down the div to draw it
|
|
||||||
var percentDownBand = (s.freq - band.start_freq) / (band.end_freq - band.start_freq) * 0.97; // not 100% due to fudge, the first and last dashes are not exactly at the top and bottom of the div as some space is needed for text
|
|
||||||
var pxDownBand = percentDownBand * BAND_COLUMN_HEIGHT_PX;
|
|
||||||
if (pxDownBand > lastSpotPxDownBand - BAND_COLUMN_SPOT_DIV_HEIGHT_PX) {
|
|
||||||
pxDownBand = lastSpotPxDownBand - BAND_COLUMN_SPOT_DIV_HEIGHT_PX; // Prevent overlap
|
|
||||||
}
|
|
||||||
s["pxDownBandLabel"] = pxDownBand;
|
|
||||||
lastSpotPxDownBand = pxDownBand;
|
|
||||||
});
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Now each spot is tagged with how far down the div it should go, add them to the DOM.
|
|
||||||
spotList.forEach(s => {
|
|
||||||
bandSpotsDiv.append(`<div class="band-spot" style="top: ${s['pxDownBandLabel']}px; border-top: 1px solid ${s.band_color}; border-left: 5px solid ${s.band_color}; border-bottom: 1px solid ${s.band_color}; border-right: 1px solid ${s.band_color};"><span class="band-spot-call">${s.dx_call}</span><span class="band-spot-info">${s.dx_call} ${(s.freq/1000000).toFixed(3)} ${s.mode}</span></div>`);
|
|
||||||
});
|
|
||||||
|
|
||||||
// Work out how tall the canvas should be. Normally this is matching the normal band column height, but if some
|
|
||||||
// spots have gone off the end of the band markers and stretched their div, we need to resize the canvas to
|
|
||||||
// match, otherwise we have nowhere to draw their connecting lines.
|
|
||||||
var canvasHeight = Math.max(BAND_COLUMN_HEIGHT_PX, lastSpotPxDownBand + BAND_COLUMN_SPOT_DIV_HEIGHT_PX);
|
|
||||||
maxHeightBand = Math.max(maxHeightBand, canvasHeight);
|
|
||||||
|
|
||||||
// Draw horizontal or diagonal lines to join up the "real" frequency with where the spot div ended up
|
|
||||||
var bandLinesCanvas = $(`<canvas class='band-lines-canvas' width='${BAND_COLUMN_CANVAS_WIDTH_PX}px' height='${canvasHeight}px' style='height:${canvasHeight}px !important;'>`);
|
|
||||||
spotList.forEach(s => {
|
|
||||||
// Work out how far down the div to draw it
|
|
||||||
var percentDownBand = (s.freq - band.start_freq) / (band.end_freq - band.start_freq) * 0.97; // not 100% due to fudge, the first and last dashes are not exactly at the top and bottom of the div as some space is needed for text
|
|
||||||
var pxDownBandFreq = (percentDownBand + 0.015) * BAND_COLUMN_HEIGHT_PX; // same fudge but add half to put the left end of the line in the right place
|
|
||||||
var pxDownBandLabel = s["pxDownBandLabel"] + (BAND_COLUMN_SPOT_DIV_HEIGHT_PX / 1.75); // line should be to the vertical text-centre spot, not to the top corner
|
|
||||||
|
|
||||||
// Draw the line on the canvas
|
|
||||||
var ctx = bandLinesCanvas[0].getContext('2d');
|
|
||||||
ctx.beginPath();
|
|
||||||
ctx.lineWidth = 2;
|
|
||||||
ctx.lineCap = "round";
|
|
||||||
ctx.strokeStyle = s.band_color;
|
|
||||||
ctx.moveTo(0, pxDownBandFreq);
|
|
||||||
ctx.lineTo(BAND_COLUMN_CANVAS_WIDTH_PX, pxDownBandLabel);
|
|
||||||
ctx.stroke();
|
|
||||||
});
|
|
||||||
|
|
||||||
// Assemble the table cell
|
|
||||||
td = $("<td>");
|
|
||||||
container = $("<div class='band-container'>");
|
|
||||||
container.append(bandLinesCanvas);
|
|
||||||
container.append(bandMarkersDiv);
|
|
||||||
container.append(bandSpotsDiv);
|
|
||||||
td.append(container);
|
|
||||||
table.find('tbody tr').append(td);
|
|
||||||
});
|
|
||||||
|
|
||||||
// Update the DOM with the band HTML
|
|
||||||
bandsContainer.html(table);
|
|
||||||
|
|
||||||
// Increase the height of the bands container so we don't have any vertical scroll bars except the browser ones
|
|
||||||
bandsContainer.css("min-height", `${maxHeightBand + 42}px`);
|
|
||||||
|
|
||||||
// Desktop mouse wheel to scroll bands horizontally if used on the headers
|
|
||||||
table.find('thead tr').on("wheel", () => {
|
|
||||||
bandsContainer.scrollLeft(bandsContainer.scrollLeft() + event.deltaY / 10.0);
|
|
||||||
return false;
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
// Iterate through a temporary list of spots, merging duplicates in a way suitable for the band panel. If two or more
|
|
||||||
// spots with the activator, mode and frequency are found, these will be merged and reduced until only one remains,
|
|
||||||
// with the best data. Note that unlike removeDuplicates(), which operates on the main spot map, this operates only
|
|
||||||
// on the temporary array of spots provided as an argument, and returns the output, for use when constructing the
|
|
||||||
// band panel.
|
|
||||||
function removeDuplicatesForBandPanel(spotList) {
|
|
||||||
const spotsToRemove = [];
|
|
||||||
spotList.forEach(function (check) {
|
|
||||||
spotList.forEach(function (s) {
|
|
||||||
if (s !== check) {
|
|
||||||
if (s.dx_call === check.dx_call && s.freq === check.freq && s.mode === check.mode) {
|
|
||||||
// Find which one to keep and which to delete
|
|
||||||
const checkSpotNewer = check.time > s.time;
|
|
||||||
const keepSpot = checkSpotNewer ? check : s;
|
|
||||||
const deleteSpot = checkSpotNewer ? s : check;
|
|
||||||
// Aggregate list of spots to remove
|
|
||||||
spotsToRemove.push(deleteSpot.uid);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
});
|
|
||||||
});
|
|
||||||
// Perform the removal
|
|
||||||
return spotList.filter(s => !spotsToRemove.includes(s.uid));
|
|
||||||
}
|
|
||||||
|
|
||||||
// Load server options. Once a successful callback is made from this, we then query spots and set up the timer to query
|
|
||||||
// spots repeatedly.
|
|
||||||
function loadOptions() {
|
|
||||||
$.getJSON('/api/v1/options', function(jsonData) {
|
|
||||||
// Store options
|
|
||||||
options = jsonData;
|
|
||||||
|
|
||||||
// Add CSS for band toggle buttons
|
|
||||||
addBandToggleColourCSS(options["bands"]);
|
|
||||||
|
|
||||||
// Populate the filters panel
|
|
||||||
generateBandsMultiToggleFilterCard(options["bands"]);
|
|
||||||
generateMultiToggleFilterCard("#dx-continent-options", "dx_continent", options["continents"]);
|
|
||||||
generateMultiToggleFilterCard("#de-continent-options", "de_continent", options["continents"]);
|
|
||||||
generateMultiToggleFilterCard("#mode-options", "mode_type", options["mode_types"]);
|
|
||||||
generateMultiToggleFilterCard("#source-options", "source", options["spot_sources"]);
|
|
||||||
|
|
||||||
// Load settings from settings storage now all the controls are available
|
|
||||||
loadSettings();
|
|
||||||
|
|
||||||
// Load spots and set up the timer
|
|
||||||
loadSpots();
|
|
||||||
setInterval(loadSpots, REFRESH_INTERVAL_SEC * 1000);
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
// Method called when any display property is changed to reload the map and persist the display settings.
|
|
||||||
function displayUpdated() {
|
|
||||||
updateMap();
|
|
||||||
saveSettings();
|
|
||||||
}
|
|
||||||
|
|
||||||
// React to toggling/closing panels
|
|
||||||
function toggleFiltersPanel() {
|
|
||||||
// If we are going to show the filters panel, hide the display panel
|
|
||||||
if (!$("#filters-area").is(":visible") && $("#display-area").is(":visible")) {
|
|
||||||
$("#display-area").hide();
|
|
||||||
$("#display-button").button("toggle");
|
|
||||||
}
|
|
||||||
$("#filters-area").toggle();
|
|
||||||
}
|
|
||||||
function closeFiltersPanel() {
|
|
||||||
$("#filters-button").button("toggle");
|
|
||||||
$("#filters-area").hide();
|
|
||||||
}
|
|
||||||
|
|
||||||
function toggleDisplayPanel() {
|
|
||||||
// If we are going to show the display panel, hide the filters panel
|
|
||||||
if (!$("#display-area").is(":visible") && $("#filters-area").is(":visible")) {
|
|
||||||
$("#filters-area").hide();
|
|
||||||
$("#filters-button").button("toggle");
|
|
||||||
}
|
|
||||||
$("#display-area").toggle();
|
|
||||||
}
|
|
||||||
function closeDisplayPanel() {
|
|
||||||
$("#display-button").button("toggle");
|
|
||||||
$("#display-area").hide();
|
|
||||||
}
|
|
||||||
|
|
||||||
// Startup
|
|
||||||
$(document).ready(function() {
|
|
||||||
// Call loadOptions(), this will then trigger loading spots and setting up timers.
|
|
||||||
loadOptions();
|
|
||||||
// Update the refresh timing display every second
|
|
||||||
setInterval(updateRefreshDisplay, 1000);
|
|
||||||
});
|
|
||||||
@@ -3,7 +3,7 @@ var markersLayer;
|
|||||||
var geodesicsLayer;
|
var geodesicsLayer;
|
||||||
var terminator;
|
var terminator;
|
||||||
|
|
||||||
// Load spots and populate the map.
|
// Load spots and populate the table.
|
||||||
function loadSpots() {
|
function loadSpots() {
|
||||||
$.getJSON('/api/v1/spots' + buildQueryString(), function(jsonData) {
|
$.getJSON('/api/v1/spots' + buildQueryString(), function(jsonData) {
|
||||||
// Store data
|
// Store data
|
||||||
@@ -23,8 +23,6 @@ function buildQueryString() {
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
str = str + "max_age=" + $("#max-spot-age option:selected").val();
|
str = str + "max_age=" + $("#max-spot-age option:selected").val();
|
||||||
// Additional filters for the map view: No dupes, no QRT, only spots with good locations
|
|
||||||
str = str + "&dedupe=true&allow_qrt=false&needs_good_location=true";
|
|
||||||
return str;
|
return str;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -34,20 +32,29 @@ function updateMap() {
|
|||||||
markersLayer.clearLayers();
|
markersLayer.clearLayers();
|
||||||
geodesicsLayer.clearLayers();
|
geodesicsLayer.clearLayers();
|
||||||
|
|
||||||
// Make new markers for all spots that match the filter
|
// Make new markers for all spots with a good location, not QRT, and not a duplicate spot within the data set.
|
||||||
|
var callsAlreadyDisplayed = [];
|
||||||
spots.forEach(function (s) {
|
spots.forEach(function (s) {
|
||||||
var m = L.marker([s["dx_latitude"], s["dx_longitude"]], {icon: getIcon(s)});
|
if (s["dx_location_good"] && (s["qrt"] == null || s["qrt"] == false)) {
|
||||||
m.bindPopup(getTooltipText(s));
|
if (!callsAlreadyDisplayed.includes(s["dx_call"])) {
|
||||||
markersLayer.addLayer(m);
|
|
||||||
|
|
||||||
// Create geodesics if required
|
// OK, create the marker
|
||||||
if ($("#mapShowGeodesics")[0].checked && s["de_latitude"] != null && s["de_longitude"] != null) {
|
var m = L.marker([s["dx_latitude"], s["dx_longitude"]], {icon: getIcon(s)});
|
||||||
var geodesic = L.geodesic([[s["de_latitude"], s["de_longitude"]], m.getLatLng()], {
|
m.bindPopup(getTooltipText(s));
|
||||||
color: s["band_color"],
|
markersLayer.addLayer(m);
|
||||||
wrap: false,
|
|
||||||
steps: 5
|
// Create geodesics if required
|
||||||
});
|
if ($("#mapShowGeodesics")[0].checked && s["de_latitude"] != null && s["de_longitude"] != null) {
|
||||||
geodesicsLayer.addLayer(geodesic);
|
var geodesic = L.geodesic([[s["de_latitude"], s["de_longitude"]], m.getLatLng()], {
|
||||||
|
color: s["band_color"],
|
||||||
|
wrap: false,
|
||||||
|
steps: 5
|
||||||
|
});
|
||||||
|
geodesicsLayer.addLayer(geodesic);
|
||||||
|
}
|
||||||
|
|
||||||
|
}
|
||||||
|
callsAlreadyDisplayed.push(s["dx_call"]);
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
@@ -93,13 +100,7 @@ function getTooltipText(s) {
|
|||||||
|
|
||||||
// Format sig_refs
|
// Format sig_refs
|
||||||
var sig_refs = "";
|
var sig_refs = "";
|
||||||
if (s["sig_refs"] && s["sig_refs_urls"] && s["sig_refs"].length == s["sig_refs_urls"].length) {
|
if (s["sig_refs"]) {
|
||||||
items = s["sig_refs"].map(s => `<span class='nowrap'>${s}</span>`)
|
|
||||||
for (var i = 0; i < items.length; i++) {
|
|
||||||
items[i] = `<a href='${s["sig_refs_urls"][i]}' target='_new' class='sig-ref-link'>${items[i]}</a>`
|
|
||||||
}
|
|
||||||
sig_refs = items.join(", ");
|
|
||||||
} else if (s["sig_refs"]) {
|
|
||||||
sig_refs = s["sig_refs"].map(s => `<span class='nowrap'>${s}</span>`).join(", ");
|
sig_refs = s["sig_refs"].map(s => `<span class='nowrap'>${s}</span>`).join(", ");
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -107,13 +108,10 @@ function getTooltipText(s) {
|
|||||||
const shortCall = s["dx_call"].split("/").sort(function (a, b) {
|
const shortCall = s["dx_call"].split("/").sort(function (a, b) {
|
||||||
return b.length - a.length;
|
return b.length - a.length;
|
||||||
})[0];
|
})[0];
|
||||||
ttt = `<span class='nowrap'><span class='icon-wrapper'>${dx_flag}</span> <a href='https://www.qrz.com/db/${shortCall}' target='_blank' class="dx-link">${s["dx_call"]}</a></span><br/>`;
|
ttt = `<span class='nowrap'>${dx_flag} <a href='https://www.qrz.com/db/${shortCall}' target='_blank' class="leaflet-popup-callsign-link">${s["dx_call"]}</a></span><br/>`;
|
||||||
|
|
||||||
// Frequency & band
|
// Frequency & band
|
||||||
ttt += `<span class='icon-wrapper'><i class='fa-solid fa-radio markerPopupIcon'></i></span> ${freq_string}`;
|
ttt += `<i class='fa-solid fa-walkie-talkie markerPopupIcon'></i> ${freq_string} (${s["band"]})`;
|
||||||
if (s["band"] != null) {
|
|
||||||
ttt += ` (${s["band"]})`;
|
|
||||||
}
|
|
||||||
// Mode
|
// Mode
|
||||||
if (s["mode"] != null) {
|
if (s["mode"] != null) {
|
||||||
ttt += ` <i class='fa-solid fa-wave-square markerPopupIcon'></i> ${s["mode"]}`;
|
ttt += ` <i class='fa-solid fa-wave-square markerPopupIcon'></i> ${s["mode"]}`;
|
||||||
@@ -121,14 +119,14 @@ 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 fa-${s["icon"]}'></i> ${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 += `<i class='fa-solid fa-clock markerPopupIcon'></i> ${moment.unix(s["time"]).fromNow()}`;
|
||||||
|
|
||||||
// Comment
|
// Comment
|
||||||
if (commentText.length > 0) {
|
if (commentText.length > 0) {
|
||||||
ttt += `<br/><span class='icon-wrapper'><i class='fa-solid fa-comment markerPopupIcon'></i></span> ${commentText}`;
|
ttt += `<br/><i class='fa-solid fa-comment markerPopupIcon'></i> ${commentText}`;
|
||||||
}
|
}
|
||||||
|
|
||||||
return ttt;
|
return ttt;
|
||||||
|
|||||||
@@ -38,7 +38,7 @@ function updateTable() {
|
|||||||
var showMode = $("#tableShowMode")[0].checked;
|
var showMode = $("#tableShowMode")[0].checked;
|
||||||
var showComment = $("#tableShowComment")[0].checked;
|
var showComment = $("#tableShowComment")[0].checked;
|
||||||
var showBearing = $("#tableShowBearing")[0].checked && userPos != null;
|
var showBearing = $("#tableShowBearing")[0].checked && userPos != null;
|
||||||
var showType = $("#tableShowType")[0].checked;
|
var showSource = $("#tableShowSource")[0].checked;
|
||||||
var showRef = $("#tableShowRef")[0].checked;
|
var showRef = $("#tableShowRef")[0].checked;
|
||||||
var showDE = $("#tableShowDE")[0].checked;
|
var showDE = $("#tableShowDE")[0].checked;
|
||||||
|
|
||||||
@@ -62,8 +62,8 @@ function updateTable() {
|
|||||||
if (showBearing) {
|
if (showBearing) {
|
||||||
table.find('thead tr').append(`<th class='hideonmobile'>Bearing</th>`);
|
table.find('thead tr').append(`<th class='hideonmobile'>Bearing</th>`);
|
||||||
}
|
}
|
||||||
if (showType) {
|
if (showSource) {
|
||||||
table.find('thead tr').append(`<th class='hideonmobile'>Type</th>`);
|
table.find('thead tr').append(`<th class='hideonmobile'>Source</th>`);
|
||||||
}
|
}
|
||||||
if (showRef) {
|
if (showRef) {
|
||||||
table.find('thead tr').append(`<th class='hideonmobile'>Ref.</th>`);
|
table.find('thead tr').append(`<th class='hideonmobile'>Ref.</th>`);
|
||||||
@@ -88,7 +88,7 @@ function updateTable() {
|
|||||||
// Format a UTC or local time for display
|
// Format a UTC or local time for display
|
||||||
var time = moment.unix(s["time"]).utc();
|
var time = moment.unix(s["time"]).utc();
|
||||||
if (useLocalTime) {
|
if (useLocalTime) {
|
||||||
time.local();
|
time = time.local();
|
||||||
}
|
}
|
||||||
var time_formatted = time.format("HH:mm");
|
var time_formatted = time.format("HH:mm");
|
||||||
|
|
||||||
@@ -109,7 +109,7 @@ function updateTable() {
|
|||||||
var khz = Math.floor((s["freq"] - (mhz * 1000000.0)) / 1000.0);
|
var khz = Math.floor((s["freq"] - (mhz * 1000000.0)) / 1000.0);
|
||||||
var hz = Math.floor(s["freq"] - (mhz * 1000000.0) - (khz * 1000.0));
|
var hz = Math.floor(s["freq"] - (mhz * 1000000.0) - (khz * 1000.0));
|
||||||
var hz_string = (hz > 0) ? hz.toFixed(0)[0] : "";
|
var hz_string = (hz > 0) ? hz.toFixed(0)[0] : "";
|
||||||
var freq_string = `<span class='freq-mhz freq-mhz-pad'>${mhz.toFixed(0)}</span><span class='freq-khz'>${khz.toFixed(0).padStart(3, '0')}</span><span class='freq-hz hideonmobile'>${hz_string}</span>`
|
var freq_string = `<span class='freq-mhz'>${mhz.toFixed(0)}</span><span class='freq-khz'>${khz.toFixed(0).padStart(3, '0')}</span><span class='freq-hz hideonmobile'>${hz_string}</span>`
|
||||||
|
|
||||||
// Format the mode
|
// Format the mode
|
||||||
mode_string = s["mode"];
|
mode_string = s["mode"];
|
||||||
@@ -140,24 +140,24 @@ function updateTable() {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Format "type" (Sig or fallback to source)
|
// Sig or fallback to source
|
||||||
var typeText = s["source"];
|
var sigSourceText = s["source"];
|
||||||
if (s["sig"]) {
|
if (s["sig"]) {
|
||||||
typeText = s["sig"];
|
sigSourceText = s["sig"];
|
||||||
}
|
}
|
||||||
|
|
||||||
// Format sig_refs
|
// Format sig_refs
|
||||||
var sig_refs = "";
|
var sig_refs = "";
|
||||||
if (s["sig_refs"] && s["sig_refs_urls"] && s["sig_refs"].length == s["sig_refs_urls"].length && s["sig_refs"].length == s["sig_refs_names"].length) {
|
if (s["sig_refs"]) {
|
||||||
items = s["sig_refs"].map(s => `<span class='nowrap'>${s}</span>`)
|
|
||||||
for (var i = 0; i < items.length; i++) {
|
|
||||||
items[i] = `<a href='${s["sig_refs_urls"][i]}' title='${s["sig_refs_names"][i]}' target='_new' class='sig-ref-link'>${items[i]}</a>`
|
|
||||||
}
|
|
||||||
sig_refs = items.join(", ");
|
|
||||||
} else if (s["sig_refs"]) {
|
|
||||||
sig_refs = s["sig_refs"].map(s => `<span class='nowrap'>${s}</span>`).join(", ");
|
sig_refs = s["sig_refs"].map(s => `<span class='nowrap'>${s}</span>`).join(", ");
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Format sig_refs title
|
||||||
|
var sig_refs_title_string = "";
|
||||||
|
if (s["sig_refs_names"]) {
|
||||||
|
sig_refs_title_string = " title=\"" + s["sig_refs_names"].join(", ") + "\"";
|
||||||
|
}
|
||||||
|
|
||||||
// Format DE flag
|
// Format DE flag
|
||||||
var de_flag = "<i class='fa-solid fa-circle-question'></i>";
|
var de_flag = "<i class='fa-solid fa-circle-question'></i>";
|
||||||
if (s["de_flag"] && s["de_flag"] != null && s["de_flag"] != "") {
|
if (s["de_flag"] && s["de_flag"] != null && s["de_flag"] != "") {
|
||||||
@@ -185,7 +185,7 @@ function updateTable() {
|
|||||||
$tr.append(`<td class='nowrap'>${time_formatted}</td>`);
|
$tr.append(`<td class='nowrap'>${time_formatted}</td>`);
|
||||||
}
|
}
|
||||||
if (showDX) {
|
if (showDX) {
|
||||||
$tr.append(`<td class='nowrap'><span class='flag-wrapper hideonmobile' title='${dx_country}'>${dx_flag}</span><a class='dx-link' href='https://qrz.com/db/${s["dx_call"]}' target='_new' title='${s["dx_name"] != null ? s["dx_name"] : ""}'>${s["dx_call"]}</a></td>`);
|
$tr.append(`<td class='nowrap'><span class='flag-wrapper hideonmobile' title='${dx_country}'>${dx_flag}</span><a class='dx-link' href='https://qrz.com/db/${s["dx_call"]}' target='_new'>${s["dx_call"]}</a></td>`);
|
||||||
}
|
}
|
||||||
if (showFreq) {
|
if (showFreq) {
|
||||||
$tr.append(`<td class='nowrap'><span class='band-bullet' title='${bandFullName}' style='color: ${s["band_color"]}'>■</span>${freq_string}</td>`);
|
$tr.append(`<td class='nowrap'><span class='band-bullet' title='${bandFullName}' style='color: ${s["band_color"]}'>■</span>${freq_string}</td>`);
|
||||||
@@ -199,25 +199,25 @@ function updateTable() {
|
|||||||
if (showBearing) {
|
if (showBearing) {
|
||||||
$tr.append(`<td class='nowrap hideonmobile'>${bearingText}</td>`);
|
$tr.append(`<td class='nowrap hideonmobile'>${bearingText}</td>`);
|
||||||
}
|
}
|
||||||
if (showType) {
|
if (showSource) {
|
||||||
$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 fa-${s["icon"]}'></i></span> ${sigSourceText}</td>`);
|
||||||
}
|
}
|
||||||
if (showRef) {
|
if (showRef) {
|
||||||
$tr.append(`<td class='hideonmobile'>${sig_refs}</td>`);
|
$tr.append(`<td class='hideonmobile'><span ${sig_refs_title_string}>${sig_refs}</span></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>`);
|
||||||
}
|
}
|
||||||
table.find('tbody').append($tr);
|
table.find('tbody').append($tr);
|
||||||
|
|
||||||
// Second row for mobile view only, containing type, ref & comment
|
// Second row for mobile view only, containing source, ref & comment
|
||||||
$tr2 = $("<tr class='hidenotonmobile'>");
|
$tr2 = $("<tr class='hidenotonmobile'>");
|
||||||
if (s["qrt"] == true) {
|
if (s["qrt"] == true) {
|
||||||
$tr2.addClass("table-faded");
|
$tr2.addClass("table-faded");
|
||||||
}
|
}
|
||||||
$td2 = $("<td colspan='100'>");
|
$td2 = $("<td colspan='100'>");
|
||||||
if (showType) {
|
if (showSource) {
|
||||||
$td2.append(`<span class='icon-wrapper'><i class='fa-solid fa-${s["icon"]}'></i></span> ${typeText} `);
|
$td2.append(`<span class='icon-wrapper'><i class='fa-solid fa-${s["icon"]}'></i></span> ${sigSourceText} `);
|
||||||
}
|
}
|
||||||
if (showRef) {
|
if (showRef) {
|
||||||
$td2.append(`${sig_refs} `);
|
$td2.append(`${sig_refs} `);
|
||||||
|
|||||||
Reference in New Issue
Block a user