Compare commits

..

12 Commits

Author SHA1 Message Date
Ian Renton
ae72649df8 Implement Parks n Peaks alert provider. Closes #56 2025-10-20 09:42:38 +01:00
Ian Renton
b4d88a4770 Provide a more useful response when a ValueError is encountered parsing a user's API request. #59 2025-10-20 08:54:15 +01:00
Ian Renton
8c2ab61049 Remove duplicate SIG 2025-10-19 15:35:28 +01:00
Ian Renton
db2376c53a Add icons to menu 2025-10-19 14:48:14 +01:00
Ian Renton
e11483e230 Implement has_sig spot filter. Closes #49 2025-10-19 14:36:22 +01:00
Ian Renton
38222b98c8 Remove tags from comments. Closes #46 2025-10-19 14:28:33 +01:00
Ian Renton
64f8b7d3b7 Rename Sig/Source column in Spot view to Type. Closes #52 2025-10-19 14:19:11 +01:00
Ian Renton
bf0b52d1d8 Band colours were in the wrong place in API docs 2025-10-19 11:10:20 +01:00
Ian Renton
333d6234e8 Forgot IOTA on the SIG list 2025-10-19 10:51:54 +01:00
Ian Renton
772d9f4341 Support de-dupe and comment filtering for spots. Closes #45 2025-10-19 10:18:01 +01:00
Ian Renton
760077b081 Provide links for SIG refs 2025-10-17 12:22:16 +01:00
Ian Renton
ec4291340a Full width tables and map on mobile #44 2025-10-17 10:55:11 +01:00
23 changed files with 725 additions and 508 deletions

View File

@@ -0,0 +1,65 @@
import logging
from datetime import datetime
import pytz
from alertproviders.http_alert_provider import HTTPAlertProvider
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],
start_time=start_time,
is_dxpedition=False)
# PNP supports a bunch of programs which should have different icons
if alert.sig == "SiOTA":
alert.icon = "wheat-awn"
elif alert.sig == "ZLOTA":
alert.icon = "kiwi-bird"
elif alert.sig == "KRMNPA":
alert.icon = "earth-oceania"
elif alert.sig in ["POTA", "SOTA", "WWFF"]:
# Don't care about an icon as this will be rejected anyway, we have better data from POTA/SOTA/WWFF direct
alert.icon = ""
else:
# Unknown programme we've never seen before
logging.warn(
"PNP alert found with sig " + alert.sig + ", developer needs to add support for this and set an icon!")
alert.icon = "question"
# If this is POTA, SOTA or WWFF data we already have it through other means, so ignore. Otherwise, add to
# the alert list.
if alert.sig not in ["POTA", "SOTA", "WWFF"]:
new_alerts.append(alert)
return new_alerts

View File

@@ -84,6 +84,10 @@ alert-providers:
class: "WWFF" class: "WWFF"
name: "WWFF" name: "WWFF"
enabled: true enabled: true
-
class: "ParksNPeaks"
name: "ParksNPeaks"
enabled: true
- -
class: "NG3K" class: "NG3K"
name: "NG3K" name: "NG3K"

View File

@@ -9,7 +9,7 @@ SOFTWARE_VERSION = "0.1"
HTTP_HEADERS = {"User-Agent": SOFTWARE_NAME + " " + 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 = ["POTA", "SOTA", "WWFF", "GMA", "WWBOTA", "HEMA", "MOTA", "ARLHS", "SiOTA", "WCA"] SIGS = ["POTA", "SOTA", "WWFF", "GMA", "WWBOTA", "HEMA", "MOTA", "ARLHS", "ILLW", "SiOTA", "WCA", "ZLOTA", "IOTA", "KRMNPA"]
# 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"]

View File

@@ -1,6 +1,7 @@
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
@@ -105,6 +106,11 @@ class Alert:
if self.dx_calls and not self.dx_names: if self.dx_calls and not self.dx_names:
self.dx_names = list(map(lambda c: lookup_helper.infer_name_from_callsign(c), self.dx_calls)) self.dx_names = list(map(lambda c: lookup_helper.infer_name_from_callsign(c), self.dx_calls))
# Clean up comments
if self.comment:
comment = re.sub(r"\(de [A-Za-z0-9]*\)", "", self.comment)
self.comment = comment.strip()
# Always create an ID based on a hash of every parameter *except* received_time. This is used as the index # Always create an ID based on a hash of every parameter *except* received_time. This is used as the index
# to a map, which as a byproduct avoids us having multiple duplicate copies of the object that are identical # to a map, which as a byproduct avoids us having multiple duplicate copies of the object that are identical
# apart from that they were retrieved from the API at different times. Note that the simple Python hash() # apart from that they were retrieved from the API at different times. Note that the simple Python hash()

View File

@@ -2,6 +2,7 @@ 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
@@ -102,6 +103,8 @@ 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
@@ -232,6 +235,13 @@ class Spot:
if self.comment and not self.qrt: if self.comment and not self.qrt:
self.qrt = "QRT" in self.comment.upper() self.qrt = "QRT" in self.comment.upper()
# Clean up comments
if self.comment:
comment = re.sub(r"\[.*]:", "", self.comment)
comment = re.sub(r"\[.*]", "", comment)
comment = re.sub(r"\"\"", "", comment)
self.comment = comment.strip()
# DX operator details lookup, using QRZ.com. This should be the last resort compared to taking the data from # DX operator details lookup, using QRZ.com. This should be the last resort compared to taking the data from
# the actual spotting service, e.g. we don't want to accidentally use a user's QRZ.com home lat/lon instead of # the actual spotting service, e.g. we don't want to accidentally use a user's QRZ.com home lat/lon instead of
# the one from the park reference they're at. # the one from the park reference they're at.

View File

@@ -31,8 +31,8 @@ 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_api(self.get_spot_list_with_filters())) bottle.get("/api/v1/spots")(lambda: self.serve_spots_api())
bottle.get("/api/v1/alerts")(lambda: self.serve_api(self.get_alert_list_with_filters())) bottle.get("/api/v1/alerts")(lambda: self.serve_alerts_api())
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())
@@ -56,6 +56,38 @@ 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)
@@ -109,6 +141,7 @@ 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)
@@ -137,6 +170,9 @@ 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())
@@ -162,8 +198,14 @@ 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 "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]
@@ -179,6 +221,22 @@ 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 "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"))]

View File

@@ -36,6 +36,7 @@ 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,

View File

@@ -43,11 +43,17 @@ class ParksNPeaks(HTTPSpotProvider):
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
if "actLocation" in source_spot and source_spot["actLocation"] != "":
spot.sig_refs_names = [source_spot["actLocation"]]
# PNP supports a bunch of programs which should have different icons # PNP supports a bunch of programs which should have different icons
if spot.sig == "SiOTA": if spot.sig == "SiOTA":
spot.icon = "wheat-awn" spot.icon = "wheat-awn"
elif spot.sig == "ZLOTA": elif spot.sig == "ZLOTA":
spot.icon = "kiwi-bird" spot.icon = "kiwi-bird"
elif spot.sig == "KRMNPA":
spot.icon = "earth-oceania"
elif spot.sig in ["POTA", "SOTA", "WWFF"]: elif spot.sig in ["POTA", "SOTA", "WWFF"]:
# Don't care about an icon as this will be rejected anyway, we have better data from POTA/SOTA/WWFF direct # Don't care about an icon as this will be rejected anyway, we have better data from POTA/SOTA/WWFF direct
spot.icon = "" spot.icon = ""
@@ -80,6 +86,8 @@ class ParksNPeaks(HTTPSpotProvider):
spot.de_call = None spot.de_call = None
break break
# Note there is currently no support for KRMNPA location lookup, see issue #61.
# If this is POTA, SOTA or WWFF data we already have it through other means, so ignore. Otherwise, add to # If this is POTA, SOTA or WWFF data we already have it through other means, so ignore. Otherwise, add to
# the spot list. # the spot list.
if spot.sig not in ["POTA", "SOTA", "WWFF"]: if spot.sig not in ["POTA", "SOTA", "WWFF"]:

View File

@@ -29,6 +29,7 @@ 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="tree",
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"],

View File

@@ -50,6 +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="mountain-sun",
time=datetime.fromisoformat(source_spot["timeStamp"]).timestamp(), time=datetime.fromisoformat(source_spot["timeStamp"]).timestamp(),
activation_score=source_spot["points"]) activation_score=source_spot["points"])

View File

@@ -18,9 +18,16 @@ 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(),

View File

@@ -29,6 +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="seedling",
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"],

View File

@@ -1,38 +1,36 @@
% rebase('webpage_base.tpl') % rebase('webpage_base.tpl')
<div class="container main-container"> <div id="info-container" class="mt-4">
<div id="info-container" class="mt-4"> <h2 class="mt-4 mb-4">About Spothole</h2>
<h2 class="mt-4 mb-4">About Spothole</h2> <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>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>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>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>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>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>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>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>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>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>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>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>This server is running Spothole version {{software_version}}.</p>
<p>This server is running Spothole version {{software_version}}.</p> <h2 id="faq" class="mt-4">FAQ</h2>
<h2 id="faq" class="mt-4">FAQ</h2> <h4 class="mt-4">"Spots"? "DX Clusters"? What does any of this mean?</h4>
<h4 class="mt-4">"Spots"? "DX Clusters"? What does any of this mean?</h4> <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>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>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>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>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>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> <h4 class="mt-4">What are "DX", "DE" and modes?</h4>
<h4 class="mt-4">What are "DX", "DE" and modes?</h4> <p>In amateur radio terminology, the "DX" contact is the "interesting" one that is using the frequency shown. 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>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">How is this better than DXheat, DXsummit, POTA's own website, etc?</h4>
<h4 class="mt-4">How is this better than DXheat, DXsummit, POTA's own website, etc?</h4> <p>It's probably not? But it's nice to have choice.</p>
<p>It's probably not? But it's nice to have choice.</p> <p>I think it's got two key advantages over those sites:</p>
<p>I think it's got two key advantages over those sites:</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&mdash;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>
<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&mdash;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> <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>
<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> <h4 class="mt-4">Why does this website ask me if I want to install it?</h4>
<h4 class="mt-4">Why does this website ask me if I want to install it?</h4> <p>Spothole is a Progressive Web App, which means you can install it on an Android or iOS device by opening the site in Chrome or Safari respectively, and clicking "Install" on the pop-up panel. It'll only prompt you once, so if you dismiss the prompt and change your mind, you'll find an Install / Add to Home Screen option on your browser's menu.</p>
<p>Spothole is a Progressive Web App, which means you can install it on an Android or iOS device by opening the site in Chrome or Safari respectively, and clicking "Install" on the pop-up panel. It'll only prompt you once, so if you dismiss the prompt and change your mind, you'll find an Install / Add to Home Screen option on your browser's menu.</p> <p>Installing Spothole on your phone is completely optional, the website works exactly the same way as the "app" does.</p>
<p>Installing Spothole on your phone is completely optional, the website works exactly the same way as the "app" does.</p> <h4 class="mt-4">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>

View File

@@ -1,151 +1,149 @@
% rebase('webpage_base.tpl') % rebase('webpage_base.tpl')
<div class="container main-container mobile-no-gutters"> <div class="mt-3">
<div class="mt-3"> <div class="row">
<div class="row"> <div class="col-auto me-auto pt-3">
<div class="col-auto me-auto pt-3"> <p id="timing-container">Loading...</p>
<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>
<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 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">
Filters Filters
</div> </div>
<div class="col-auto d-inline-flex"> <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> <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 row-cols-md-3 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> </div>
<div class="col">
</div> <div class="card">
<div class="card-body"> <div class="card-body">
<div class="row row-cols-1 row-cols-md-3 g-4"> <h5 class="card-title">Sources</h5>
<div class="col"> <p id="source-options" class="card-text spothole-card-text"></p>
<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 class="col"> </div>
<div class="card"> <div class="col">
<div class="card-body"> <div class="card">
<h5 class="card-title">Sources</h5> <div class="card-body">
<p id="source-options" class="card-text spothole-card-text"></p> <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>
</div> <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 class="col"> <option value="10800">3 hours</option>
<div class="card"> <option value="43200">12 hours</option>
<div class="card-body"> <option value="86400" selected>24 hours</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="604800">1 week</option>
<p class="card-text spothole-card-text"> <option value="2419200">4 weeks</option>
Hide any alerts lasting more than:<br/> <option value="9999999999">No limit</option>
<select id="max-duration" class="storeable-select form-select" onclick="filtersUpdated();" style="width: 8em; display: inline-block;"> </select>
<option value="10800">3 hours</option> </p>
<option value="43200">12 hours</option> <p class='card-text spothole-card-text' style='line-height: 1.5em !important;'>
<option value="86400" selected>24 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="604800">1 week</option> </p>
<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 class="card-body">
<div id="display-container" class="row row-cols-1 row-cols-md-3 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> </div>
<div class="col">
</div> <div class="card">
<div class="card-body"> <div class="card-body">
<div id="display-container" class="row row-cols-1 row-cols-md-3 g-4"> <h5 class="card-title">Number of Alerts</h5>
<div class="col"> <p class="card-text spothole-card-text">Show up to
<div class="card"> <select id="alerts-to-fetch" class="storeable-select form-select ms-2" oninput="filtersUpdated();" style="width: 5em;display: inline-block;">
<div class="card-body"> <option value="25">25</option>
<h5 class="card-title">Time Zone</h5> <option value="50">50</option>
<p class="card-text spothole-card-text"> Use <option value="100" selected>100</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="200">200</option>
<option value="UTC" selected>UTC</option> <option value="500">500</option>
<option value="local">Local time</option> </select>
</select> alerts
</p> </p>
</div>
</div> </div>
</div> </div>
<div class="col"> </div>
<div class="card"> <div class="col">
<div class="card-body"> <div class="card">
<h5 class="card-title">Number of Alerts</h5> <div class="card-body">
<p class="card-text spothole-card-text">Show up to <h5 class="card-title">Table Data</h5>
<select id="alerts-to-fetch" class="storeable-select form-select ms-2" oninput="filtersUpdated();" style="width: 5em;display: inline-block;"> <div class="form-group">
<option value="25">25</option> <div class="form-check form-check-inline">
<option value="50">50</option> <input class="form-check-input storeable-checkbox" type="checkbox" id="tableShowStartTime" value="tableShowStartTime" oninput="columnsUpdated();" checked>
<option value="100" selected>100</option> <label class="form-check-label" for="tableShowStartTime">Start Time</label>
<option value="200">200</option> </div>
<option value="500">500</option> <div class="form-check form-check-inline">
</select> <input class="form-check-input storeable-checkbox" type="checkbox" id="tableShowEndTime" value="tableShowEndTime" oninput="columnsUpdated();" checked>
alerts <label class="form-check-label" for="tableShowEndTime">End Time</label>
</p> </div>
</div> <div class="form-check form-check-inline">
</div> <input class="form-check-input storeable-checkbox" type="checkbox" id="tableShowDX" value="tableShowDX" oninput="columnsUpdated();" checked>
</div> <label class="form-check-label" for="tableShowDX">DX</label>
<div class="col"> </div>
<div class="card"> <div class="form-check form-check-inline">
<div class="card-body"> <input class="form-check-input storeable-checkbox" type="checkbox" id="tableShowFreqsModes" value="tableShowFreqsModes" oninput="columnsUpdated();" checked>
<h5 class="card-title">Table Data</h5> <label class="form-check-label" for="tableShowFreqsModes">Frequencies & Modes</label>
<div class="form-group"> </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="tableShowStartTime" value="tableShowStartTime" 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="tableShowStartTime">Start Time</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="tableShowEndTime" value="tableShowEndTime" 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="tableShowEndTime">End Time</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="tableShowDX" value="tableShowDX" 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="tableShowDX">DX</label> <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="tableShowFreqsModes" value="tableShowFreqsModes" oninput="columnsUpdated();" checked>
<label class="form-check-label" for="tableShowFreqsModes">Frequencies & Modes</label>
</div>
<div class="form-check form-check-inline">
<input class="form-check-input storeable-checkbox" type="checkbox" id="tableShowComment" value="tableShowComment" oninput="columnsUpdated();" checked>
<label class="form-check-label" for="tableShowComment">Comment</label>
</div>
<div class="form-check form-check-inline">
<input class="form-check-input storeable-checkbox" type="checkbox" id="tableShowSource" value="tableShowSource" oninput="columnsUpdated();" checked>
<label class="form-check-label" for="tableShowSource">Source</label>
</div>
<div class="form-check form-check-inline">
<input class="form-check-input storeable-checkbox" type="checkbox" id="tableShowRef" value="tableShowRef" oninput="columnsUpdated();" checked>
<label class="form-check-label" for="tableShowRef">Ref.</label>
</div>
</div> </div>
</div> </div>
</div> </div>
@@ -153,10 +151,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>

View File

@@ -1,8 +1,5 @@
% rebase('webpage_base.tpl') % rebase('webpage_base.tpl')
<div class="container main-container"> <redoc spec-url="/apidocs/openapi.yml"></redoc>
<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>

View File

@@ -57,17 +57,16 @@
</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">Spots</a></li> <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="/map" class="nav-link" id="nav-link-map">Map</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="/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>
@@ -75,7 +74,6 @@
</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>

View File

@@ -1,117 +1,115 @@
% rebase('webpage_base.tpl') % rebase('webpage_base.tpl')
<div class="container main-container mobile-no-gutters"> <div id="map">
<div id="map"> <div class="mt-3 px-3" style="z-index: 1002; position: relative;">
<div class="mt-3 px-3" style="z-index: 1002; position: relative;"> <div class="row">
<div class="row"> <div class="col-auto me-auto pt-3"></div>
<div class="col-auto me-auto pt-3"></div> <div class="col-auto">
<div class="col-auto"> <p class="d-inline-flex gap-1">
<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="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>
<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>
</p>
</div>
</div> </div>
</div>
<div id="filters-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">
Filters Filters
</div> </div>
<div class="col-auto d-inline-flex"> <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> <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>
<div class="col"> <div class="card-body">
<div class="card"> <div class="row row-cols-1 g-4 mb-4">
<div class="card-body"> <div class="col">
<h5 class="card-title">Bands</h5> <div class="card">
<p id="band-options" class="card-text spothole-card-text"></p> <div class="card-body">
</div> <h5 class="card-title">Bands</h5>
<p id="band-options" class="card-text spothole-card-text"></p>
</div> </div>
</div> </div>
</div> </div>
<div class="row row-cols-1 row-cols-md-4 g-4"> </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">DX Continent</h5> <div class="card-body">
<p id="dx-continent-options" class="card-text spothole-card-text"></p> <h5 class="card-title">DX Continent</h5>
</div> <p id="dx-continent-options" class="card-text spothole-card-text"></p>
</div> </div>
</div> </div>
<div class="col"> </div>
<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">DE Continent</h5>
</div> <p id="de-continent-options" class="card-text spothole-card-text"></p>
</div> </div>
</div> </div>
<div class="col"> </div>
<div class="card"> <div class="col">
<div class="card-body"> <div class="card">
<h5 class="card-title">Modes</h5> <div class="card-body">
<p id="mode-options" class="card-text spothole-card-text"></p> <h5 class="card-title">Modes</h5>
</div> <p id="mode-options" class="card-text spothole-card-text"></p>
</div> </div>
</div> </div>
<div class="col"> </div>
<div class="card"> <div class="col">
<div class="card-body"> <div class="card">
<h5 class="card-title">Sources</h5> <div class="card-body">
<p id="source-options" class="card-text spothole-card-text"></p> <h5 class="card-title">Sources</h5>
</div> <p id="source-options" class="card-text spothole-card-text"></p>
</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 id="display-container" class="row row-cols-1 row-cols-md-4 g-4"> </div>
<div class="col"> <div class="card-body">
<div class="card"> <div id="display-container" class="row row-cols-1 row-cols-md-4 g-4">
<div class="card-body"> <div class="col">
<h5 class="card-title">Spot Age</h5> <div class="card">
<p class="card-text spothole-card-text">Last <div class="card-body">
<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;"> <h5 class="card-title">Spot Age</h5>
<option value="300">5</option> <p class="card-text spothole-card-text">Last
<option value="600">10</option> <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="1800" selected>30</option> <option value="300">5</option>
<option value="3600">60</option> <option value="600">10</option>
</select> <option value="1800" selected>30</option>
minutes <option value="3600">60</option>
</p> </select>
</div> minutes
</p>
</div> </div>
</div> </div>
<div class="col"> </div>
<div class="card"> <div class="col">
<div class="card-body"> <div class="card">
<h5 class="card-title">Map Features</h5> <div class="card-body">
<div class="form-group"> <h5 class="card-title">Map Features</h5>
<div class="form-check form-check-inline"> <div class="form-group">
<input class="form-check-input storeable-checkbox" type="checkbox" id="mapShowGeodesics" value="mapShowGeodesics" oninput="displayUpdated();"> <div class="form-check form-check-inline">
<label class="form-check-label" for="mapShowGeodesics">Geodesic Lines</label> <input class="form-check-input storeable-checkbox" type="checkbox" id="mapShowGeodesics" value="mapShowGeodesics" oninput="displayUpdated();">
</div> <label class="form-check-label" for="mapShowGeodesics">Geodesic Lines</label>
</div> </div>
</div> </div>
</div> </div>

View File

@@ -1,242 +1,240 @@
% rebase('webpage_base.tpl') % rebase('webpage_base.tpl')
<div class="container main-container mobile-no-gutters"> <div id="intro-box" class="mt-3">
<div id="intro-box" class="mt-3"> <div class="alert alert-primary alert-dismissible fade show" role="alert">
<div class="alert alert-primary alert-dismissible fade show" role="alert"> <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.
<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. <button type="button" id="intro-box-dismiss" class="btn-close" data-bs-dismiss="alert" aria-label="Close"></button>
<button type="button" id="intro-box-dismiss" class="btn-close" data-bs-dismiss="alert" aria-label="Close"></button> </div>
</div> </div>
</div>
<div class="mt-3"> <div class="mt-3">
<div class="row"> <div class="row">
<div class="col-auto me-auto pt-3"> <div class="col-auto me-auto pt-3">
<p id="timing-container">Loading...</p> <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 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 id="filters-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">
Filters Filters
</div> </div>
<div class="col-auto d-inline-flex"> <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> <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>
<div class="col"> <div class="card-body">
<div class="card"> <div class="row row-cols-1 g-4 mb-4">
<div class="card-body"> <div class="col">
<h5 class="card-title">Bands</h5> <div class="card">
<p id="band-options" class="card-text spothole-card-text"></p> <div class="card-body">
</div> <h5 class="card-title">Bands</h5>
<p id="band-options" class="card-text spothole-card-text"></p>
</div> </div>
</div> </div>
</div> </div>
<div class="row row-cols-1 row-cols-md-4 g-4"> </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">DX Continent</h5> <div class="card-body">
<p id="dx-continent-options" class="card-text spothole-card-text"></p> <h5 class="card-title">DX Continent</h5>
</div> <p id="dx-continent-options" class="card-text spothole-card-text"></p>
</div> </div>
</div> </div>
<div class="col"> </div>
<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">DE Continent</h5>
</div> <p id="de-continent-options" class="card-text spothole-card-text"></p>
</div> </div>
</div> </div>
<div class="col"> </div>
<div class="card"> <div class="col">
<div class="card-body"> <div class="card">
<h5 class="card-title">Modes</h5> <div class="card-body">
<p id="mode-options" class="card-text spothole-card-text"></p> <h5 class="card-title">Modes</h5>
</div> <p id="mode-options" class="card-text spothole-card-text"></p>
</div> </div>
</div> </div>
<div class="col"> </div>
<div class="card"> <div class="col">
<div class="card-body"> <div class="card">
<h5 class="card-title">Sources</h5> <div class="card-body">
<p id="source-options" class="card-text spothole-card-text"></p> <h5 class="card-title">Sources</h5>
</div> <p id="source-options" class="card-text spothole-card-text"></p>
</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">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> </div>
<div class="col">
</div> <div class="card">
<div class="card-body"> <div class="card-body">
<div id="display-container" class="row row-cols-1 row-cols-md-4 g-4"> <h5 class="card-title">Number of Spots</h5>
<div class="col"> <p class="card-text spothole-card-text">Show up to
<div class="card"> <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;">
<div class="card-body"> <option value="10">10</option>
<h5 class="card-title">Time Zone</h5> <option value="25">25</option>
<p class="card-text spothole-card-text"> Use <option value="50" selected>50</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="100">100</option>
<option value="UTC" selected>UTC</option> </select>
<option value="local">Local time</option> spots
</select> </p>
</p>
</div>
</div> </div>
</div> </div>
<div class="col"> </div>
<div class="card"> <div class="col">
<div class="card-body"> <div class="card">
<h5 class="card-title">Number of Spots</h5> <div class="card-body">
<p class="card-text spothole-card-text">Show up to <h5 class="card-title">Table Columns</h5>
<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;"> <div class="form-group">
<option value="10">10</option> <div class="form-check form-check-inline">
<option value="25">25</option> <input class="form-check-input storeable-checkbox" type="checkbox" id="tableShowTime" value="tableShowTime" oninput="columnsUpdated();" checked>
<option value="50" selected>50</option> <label class="form-check-label" for="tableShowTime">Time</label>
<option value="100">100</option> </div>
</select> <div class="form-check form-check-inline">
spots <input class="form-check-input storeable-checkbox" type="checkbox" id="tableShowDX" value="tableShowDX" oninput="columnsUpdated();" checked>
</p> <label class="form-check-label" for="tableShowDX">DX</label>
</div> </div>
</div> <div class="form-check form-check-inline">
</div> <input class="form-check-input storeable-checkbox" type="checkbox" id="tableShowFreq" value="tableShowFreq" oninput="columnsUpdated();" checked>
<div class="col"> <label class="form-check-label" for="tableShowFreq">Frequency</label>
<div class="card"> </div>
<div class="card-body"> <div class="form-check form-check-inline">
<h5 class="card-title">Table Columns</h5> <input class="form-check-input storeable-checkbox" type="checkbox" id="tableShowMode" value="tableShowMode" oninput="columnsUpdated();" checked>
<div class="form-group"> <label class="form-check-label" for="tableShowMode">Mode</label>
<div class="form-check form-check-inline"> </div>
<input class="form-check-input storeable-checkbox" type="checkbox" id="tableShowTime" value="tableShowTime" oninput="columnsUpdated();" checked> <div class="form-check form-check-inline">
<label class="form-check-label" for="tableShowTime">Time</label> <input class="form-check-input storeable-checkbox" type="checkbox" id="tableShowComment" value="tableShowComment" oninput="columnsUpdated();" checked>
</div> <label class="form-check-label" for="tableShowComment">Comment</label>
<div class="form-check form-check-inline"> </div>
<input class="form-check-input storeable-checkbox" type="checkbox" id="tableShowDX" value="tableShowDX" oninput="columnsUpdated();" checked> <div class="form-check form-check-inline">
<label class="form-check-label" for="tableShowDX">DX</label> <input class="form-check-input storeable-checkbox" type="checkbox" id="tableShowBearing" value="tableShowBearing" oninput="columnsUpdated();">
</div> <label class="form-check-label" for="tableShowBearing">Bearing</label>
<div class="form-check form-check-inline"> </div>
<input class="form-check-input storeable-checkbox" type="checkbox" id="tableShowFreq" value="tableShowFreq" oninput="columnsUpdated();" checked> <div class="form-check form-check-inline">
<label class="form-check-label" for="tableShowFreq">Frequency</label> <input class="form-check-input storeable-checkbox" type="checkbox" id="tableShowType" value="tableShowType" oninput="columnsUpdated();" checked>
</div> <label class="form-check-label" for="tableShowType">Type</label>
<div class="form-check form-check-inline"> </div>
<input class="form-check-input storeable-checkbox" type="checkbox" id="tableShowMode" value="tableShowMode" oninput="columnsUpdated();" checked> <div class="form-check form-check-inline">
<label class="form-check-label" for="tableShowMode">Mode</label> <input class="form-check-input storeable-checkbox" type="checkbox" id="tableShowRef" value="tableShowRef" oninput="columnsUpdated();" checked>
</div> <label class="form-check-label" for="tableShowRef">Ref.</label>
<div class="form-check form-check-inline"> </div>
<input class="form-check-input storeable-checkbox" type="checkbox" id="tableShowComment" value="tableShowComment" oninput="columnsUpdated();" checked> <div class="form-check form-check-inline">
<label class="form-check-label" for="tableShowComment">Comment</label> <input class="form-check-input storeable-checkbox" type="checkbox" id="tableShowDE" value="tableShowDE" oninput="columnsUpdated();" checked>
</div> <label class="form-check-label" for="tableShowDE">DE</label>
<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> </div>
</div> </div>
<div class="col"> </div>
<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">Location</h5>
<label for="userGrid">Your grid:</label> <div class="form-group spothole-card-text">
<input type="text" class="storeable-text form-control" id="userGrid" placeholder="AA00aa" oninput="userGridUpdated();" style="width: 10em; display: inline-block;"> <label for="userGrid">Your grid:</label>
</div> <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> </div>
</div>
<div id="add-spot-area" class="appearing-panel card mb-3"> <div id="add-spot-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 Add a Spot
</div> </div>
<div class="col-auto d-inline-flex"> <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> <button id="close-add-spot-button" type="button" class="btn-close btn-close-white" aria-label="Close" onclick="closeAddSpotPanel();"></button>
</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>
<div class="card-body">
<div class="alert alert-warning alert-dismissible fade show mb-0 mt-4" role="alert"> <form class="row g-2">
Please note that spots added to Spothole are not currently sent "upstream" to DX clusters or xOTA spotting sites. <div class="col-auto">
<button type="button" class="btn-close" data-bs-dismiss="alert" aria-label="Close"></button> <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>
<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 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>

View File

@@ -1,8 +1,6 @@
% rebase('webpage_base.tpl') % rebase('webpage_base.tpl')
<div class="container main-container"> <div id="status-container" class="row row-cols-1 row-cols-md-4 g-4 mt-4"></div>
<div id="status-container" class="row row-cols-1 row-cols-md-4 g-4 mt-4"></div>
</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>

View File

@@ -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. 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 provided as an argument. To select more than one SIG, supply a comma-separated list."
required: false required: false
schema: schema:
type: string type: string
@@ -76,6 +76,20 @@ 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 from a Special Interest Grous 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: 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."
@@ -168,6 +182,19 @@ 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
responses: responses:
'200': '200':
description: Success description: Success
@@ -241,6 +268,13 @@ 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."
@@ -667,6 +701,13 @@ 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
@@ -680,6 +721,12 @@ 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
@@ -688,6 +735,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"
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.
@@ -806,6 +861,13 @@ 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
@@ -827,14 +889,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"
source: source:
type: string type: string
description: Where we got the alert from. description: Where we got the alert from.

View File

@@ -14,7 +14,7 @@
/* GENERAL PAGE LAYOUT */ /* GENERAL PAGE LAYOUT */
div.main-container { div.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,10 +92,13 @@ 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 {
@@ -117,6 +120,10 @@ 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 {
@@ -142,12 +149,6 @@ div#map {
font-family: var(--bs-body-font-family) !important; font-family: var(--bs-body-font-family) !important;
} }
a.leaflet-popup-callsign-link {
color: black;
font-weight: bold;
text-decoration: none;
}
/* GENERAL MOBILE SUPPORT */ /* GENERAL MOBILE SUPPORT */
@@ -155,9 +156,9 @@ a.leaflet-popup-callsign-link {
.hideonmobile { .hideonmobile {
display: none !important; display: none !important;
} }
.mobile-no-gutters { div#map, div#table-container {
padding-left: 0 !important; margin-left: -1em;
padding-right: 0 !important; margin-right: -1em;
} }
} }

View File

@@ -100,7 +100,13 @@ function getTooltipText(s) {
// Format sig_refs // Format sig_refs
var sig_refs = ""; var sig_refs = "";
if (s["sig_refs"]) { if (s["sig_refs"] && s["sig_refs_urls"] && s["sig_refs"].length == s["sig_refs_urls"].length) {
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(", ");
} }
@@ -108,10 +114,13 @@ 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'>${dx_flag} <a href='https://www.qrz.com/db/${shortCall}' target='_blank' class="leaflet-popup-callsign-link">${s["dx_call"]}</a></span><br/>`; 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/>`;
// Frequency & band // Frequency & band
ttt += `<i class='fa-solid fa-walkie-talkie markerPopupIcon'></i>&nbsp;${freq_string} (${s["band"]})`; ttt += `<span class='icon-wrapper'><i class='fa-solid fa-radio markerPopupIcon'></i></span>&nbsp;${freq_string}`;
if (s["band"] != null) {
ttt += ` (${s["band"]})`;
}
// Mode // Mode
if (s["mode"] != null) { if (s["mode"] != null) {
ttt += ` &nbsp;&nbsp; <i class='fa-solid fa-wave-square markerPopupIcon'></i>&nbsp;${s["mode"]}`; ttt += ` &nbsp;&nbsp; <i class='fa-solid fa-wave-square markerPopupIcon'></i>&nbsp;${s["mode"]}`;
@@ -119,14 +128,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>&nbsp;${sigSourceText} ${sig_refs}</span><br/>`; ttt += `<span class='nowrap'><span class='icon-wrapper'><i class='fa-solid fa-${s["icon"]}'></i></span>&nbsp;${sigSourceText} ${sig_refs}</span><br/>`;
// Time // Time
ttt += `<i class='fa-solid fa-clock markerPopupIcon'></i>&nbsp;${moment.unix(s["time"]).fromNow()}`; ttt += `<span class='icon-wrapper'><i class='fa-solid fa-clock markerPopupIcon'></i></span>&nbsp;${moment.unix(s["time"]).fromNow()}`;
// Comment // Comment
if (commentText.length > 0) { if (commentText.length > 0) {
ttt += `<br/><i class='fa-solid fa-comment markerPopupIcon'></i> ${commentText}`; ttt += `<br/><span class='icon-wrapper'><i class='fa-solid fa-comment markerPopupIcon'></i></span> ${commentText}`;
} }
return ttt; return ttt;

View File

@@ -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 showSource = $("#tableShowSource")[0].checked; var showType = $("#tableShowType")[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 (showSource) { if (showType) {
table.find('thead tr').append(`<th class='hideonmobile'>Source</th>`); table.find('thead tr').append(`<th class='hideonmobile'>Type</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>`);
@@ -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'>${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 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>`
// Format the mode // Format the mode
mode_string = s["mode"]; mode_string = s["mode"];
@@ -140,15 +140,21 @@ function updateTable() {
} }
} }
// Sig or fallback to source // Format "type" (Sig or fallback to source)
var sigSourceText = s["source"]; var typeText = s["source"];
if (s["sig"]) { if (s["sig"]) {
sigSourceText = s["sig"]; typeText = s["sig"];
} }
// Format sig_refs // Format sig_refs
var sig_refs = ""; var sig_refs = "";
if (s["sig_refs"]) { if (s["sig_refs"] && s["sig_refs_urls"] && s["sig_refs"].length == s["sig_refs_urls"].length) {
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(", ");
} }
@@ -199,8 +205,8 @@ function updateTable() {
if (showBearing) { if (showBearing) {
$tr.append(`<td class='nowrap hideonmobile'>${bearingText}</td>`); $tr.append(`<td class='nowrap hideonmobile'>${bearingText}</td>`);
} }
if (showSource) { if (showType) {
$tr.append(`<td class='nowrap hideonmobile'><span class='icon-wrapper'><i class='fa-solid fa-${s["icon"]}'></i></span> ${sigSourceText}</td>`); $tr.append(`<td class='nowrap hideonmobile'><span class='icon-wrapper'><i class='fa-solid fa-${s["icon"]}'></i></span> ${typeText}</td>`);
} }
if (showRef) { if (showRef) {
$tr.append(`<td class='hideonmobile'><span ${sig_refs_title_string}>${sig_refs}</span></td>`); $tr.append(`<td class='hideonmobile'><span ${sig_refs_title_string}>${sig_refs}</span></td>`);
@@ -210,14 +216,14 @@ function updateTable() {
} }
table.find('tbody').append($tr); table.find('tbody').append($tr);
// Second row for mobile view only, containing source, ref & comment // Second row for mobile view only, containing type, 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 (showSource) { if (showType) {
$td2.append(`<span class='icon-wrapper'><i class='fa-solid fa-${s["icon"]}'></i></span> ${sigSourceText} `); $td2.append(`<span class='icon-wrapper'><i class='fa-solid fa-${s["icon"]}'></i></span> ${typeText} `);
} }
if (showRef) { if (showRef) {
$td2.append(`${sig_refs} `); $td2.append(`${sig_refs} `);