75 changed files with 5459 additions and 136 deletions
+6 -4
View File
@@ -348,9 +348,9 @@ To navigate your way around the source code, this list may help.
* `/webassets` - Root for static files served by the web server * `/webassets` - Root for static files served by the web server
* `/webassets/apidocs` - Contains the OpenAPI spec (`openapi.yml`) * `/webassets/apidocs` - Contains the OpenAPI spec (`openapi.yml`)
* `/webassets/css` - CSS files used by the web front-end * `/webassets/css` - CSS files used by the web front-end
* `/webassets/fa` - a copy of the FontAwesome library
* `/webassets/img` - image files used by the web front-end * `/webassets/img` - image files used by the web front-end
* `/webassets/js` - JavaScript used by the web front-end * `/webassets/js` - JavaScript used by the web front-end
* `/webassets/vendor` - Third-party libraries (CSS, JS, fonts and images)
*Miscellaneous* *Miscellaneous*
@@ -383,11 +383,13 @@ As well as being my work, I have also gratefully received feature patches from S
The project contains GeoJSON files for CQ and ITU zones, in the `/datafiles/` directory. These are MIT-licenced and, to my knowledge, created by HA8TKS for his CQ and ITU zone layers for Leaflet. The project contains GeoJSON files for CQ and ITU zones, in the `/datafiles/` directory. These are MIT-licenced and, to my knowledge, created by HA8TKS for his CQ and ITU zone layers for Leaflet.
The project contains a self-hosted copy of Font Awesome's free library, in the `/webassets/fa/` directory. This is subject to Font Awesome's licence and is not covered by the overall licence declared in the `LICENSE` file. This approach was taken in preference to using their hosted kits due to the popularity of this project exceeding the page view limit for their free hosted offering.
The project contains a set of flag icons generated using the "Noto Color Emoji" font on a Debian system, in the `/webassets/img/flags/` directory. The project contains a set of flag icons generated using the "Noto Color Emoji" font on a Debian system, in the `/webassets/img/flags/` directory.
The software uses a number of Python libraries as listed in `requirements.txt`, and a number of JavaScript libraries such as jQuery, Leaflet and Bootstrap. This project would not have been possible without these libraries, so many thanks to their developers. The software uses a number of Python libraries as listed in `requirements.txt`, and a number of JavaScript libraries. This project would not have been possible without these libraries, so many thanks to their developers.
### Third Party Libraries
A number of third-party libraries are self-hosted in the `/webassets/vendor/` directory. These files are subject to their own licences and are not covered by the overall licence declared in the `LICENSE` file.
Particular thanks go to country-files.com for providing country lookup data for amateur radio, to K0SWE for [this JSON-formatted DXCC data](https://github.com/k0swe/dxcc-json/), and to the developers of `pyhamtools` for making it easy to use country-files.com data as well as QRZ.com and Clublog lookup. Particular thanks go to country-files.com for providing country lookup data for amateur radio, to K0SWE for [this JSON-formatted DXCC data](https://github.com/k0swe/dxcc-json/), and to the developers of `pyhamtools` for making it easy to use country-files.com data as well as QRZ.com and Clublog lookup.
+1 -1
View File
@@ -12,7 +12,7 @@ class ParksNPeaks(HTTPAlertProvider):
"""Alert provider for Parks n Peaks""" """Alert provider for Parks n Peaks"""
POLL_INTERVAL_SEC = 1800 POLL_INTERVAL_SEC = 1800
ALERTS_URL = "http://parksnpeaks.org/api/ALERTS/" ALERTS_URL = "https://parksnpeaks.org/api/ALERTS/"
def __init__(self, provider_config): def __init__(self, provider_config):
super().__init__(provider_config, self.ALERTS_URL, self.POLL_INTERVAL_SEC) super().__init__(provider_config, self.ALERTS_URL, self.POLL_INTERVAL_SEC)
+14
View File
@@ -212,6 +212,20 @@ clublog-api-key: ""
# Allow submitting spots to the Spothole API? # Allow submitting spots to the Spothole API?
allow-spotting: true allow-spotting: true
# Allow upstream submission of spots to external providers (POTA, SOTA, etc.) via the API?
# Requires allow-spotting to also be true. Set to false to only accept spots into the local
# Spothole database, without forwarding them to any external service.
allow-upstream-spotting: true
# Google reCAPTCHA v2 keys for CAPTCHA protection on upstream spot submission. Both keys must be set to enable CAPTCHA.
# Leave both empty to disable CAPTCHA (e.g. for a private/trusted server) or if allow-spotting is false, in which case
# they will do nothing. Note that with CAPTCHA enabled, this will prevent third-party clients submitting spots through
# Spothole unless the clients are web-based, use the same site key, have their domains enabled in your reCAPTCHA config,
# and of course their user solves the CAPTCHA.
# You can sign up for reCAPTCHA at https://www.google.com/recaptcha/
recaptcha-site-key: ""
recaptcha-secret-key: ""
# Options for the web UI. # Options for the web UI.
web-ui-options: web-ui-options:
spot-count: [10, 25, 50, 100] spot-count: [10, 25, 50, 100]
+8 -1
View File
@@ -14,20 +14,27 @@ with open("config.yml") as f:
config = yaml.safe_load(f) config = yaml.safe_load(f)
logging.info("Loaded config.") logging.info("Loaded config.")
# TODO load other keys with config.get(key, default) instead of config[key]
BASE_URL = config["base-url"] BASE_URL = config["base-url"]
MAX_SPOT_AGE = config["max-spot-age-sec"] MAX_SPOT_AGE = config["max-spot-age-sec"]
MAX_ALERT_AGE = config["max-alert-age-sec"] MAX_ALERT_AGE = config["max-alert-age-sec"]
SERVER_OWNER_CALLSIGN = config["server-owner-callsign"] SERVER_OWNER_CALLSIGN = config["server-owner-callsign"]
WEB_SERVER_PORT = config["web-server-port"] WEB_SERVER_PORT = config["web-server-port"]
ALLOW_SPOTTING = config["allow-spotting"] ALLOW_SPOTTING = config["allow-spotting"]
ALLOW_UPSTREAM_SPOTTING = config.get("allow-upstream-spotting", True)
WEB_UI_OPTIONS = config["web-ui-options"] WEB_UI_OPTIONS = config["web-ui-options"]
API_ONLY_MODE = config.get("api-only-mode", False) API_ONLY_MODE = config.get("api-only-mode", False)
RECAPTCHA_SECRET_KEY = config.get("recaptcha-secret-key", "")
RECAPTCHA_SITE_KEY = config.get("recaptcha-site-key", "")
# For ease of config, each spot provider owns its own config about whether it should be enabled by default in the web UI # For ease of config, each spot provider owns its own config about whether it should be enabled by default in the web UI
# but for consistency we provide this to the front-end in web-ui-options because it has no impact outside of the web UI. # but for consistency we provide this to the front-end in web-ui-options because it has no impact outside of the web UI.
WEB_UI_OPTIONS["spot-providers-enabled-by-default"] = [p["name"] for p in config["spot-providers"] if p["enabled"] and ( WEB_UI_OPTIONS["spot-providers-enabled-by-default"] = [p["name"] for p in config["spot-providers"] if p["enabled"] and (
"enabled-by-default-in-web-ui" not in p or p["enabled-by-default-in-web-ui"])] "enabled-by-default-in-web-ui" not in p or p["enabled-by-default-in-web-ui"])]
# If spotting to this server is enabled, "API" is another valid spot source even though it does not come from # If spotting to this server is enabled, "API" is another valid spot source even though it does not come from
# one of our proviers. We set that to also be enabled by default. # one of our proviers. We set that to also be enabled by default. We can also include the reCaptcha site key so the UI
# can access it.
if ALLOW_SPOTTING: if ALLOW_SPOTTING:
WEB_UI_OPTIONS["spot-providers-enabled-by-default"].append("API") WEB_UI_OPTIONS["spot-providers-enabled-by-default"].append("API")
WEB_UI_OPTIONS["recaptcha-site-key"] = RECAPTCHA_SITE_KEY
WEB_UI_OPTIONS["allow-upstream-spotting"] = ALLOW_SPOTTING and ALLOW_UPSTREAM_SPOTTING
+4 -2
View File
@@ -11,7 +11,7 @@ HAMQTH_PRG = ("Spothole v" + SOFTWARE_VERSION + " operated by " + SERVER_OWNER_C
# Special Interest Groups # Special Interest Groups
SIGS = [ SIGS = [
SIG(name="POTA", description="Parks on the Air", ref_regex=r"[A-Z]{2}\-\d{4,5}"), SIG(name="POTA", description="Parks on the Air", ref_regex=r"([A-Z]{2}\-\d{4,5}|K\-TEST)"),
SIG(name="SOTA", description="Summits on the Air", ref_regex=r"[A-Z0-9]{1,3}\/[A-Z]{2}\-\d{3}"), SIG(name="SOTA", description="Summits on the Air", ref_regex=r"[A-Z0-9]{1,3}\/[A-Z]{2}\-\d{3}"),
SIG(name="WWFF", description="World Wide Flora & Fauna", ref_regex=r"[A-Z0-9]{1,3}FF\-\d{4}"), SIG(name="WWFF", description="World Wide Flora & Fauna", ref_regex=r"[A-Z0-9]{1,3}FF\-\d{4}"),
SIG(name="GMA", description="Global Mountain Activity", ref_regex=r"[A-Z0-9]{1,3}\/[A-Z]{2}\-\d{3}"), SIG(name="GMA", description="Global Mountain Activity", ref_regex=r"[A-Z0-9]{1,3}\/[A-Z]{2}\-\d{3}"),
@@ -37,10 +37,12 @@ SIGS = [
# 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", "FUSION", "M17"]
DATA_MODES = ["DATA", "FT8", "FT4", "RTTY", "SSTV", "JS8", "HELL", "PSK", "OLIVIA", "PKT", "MSK144"] DATA_MODES = ["DATA", "FT8", "FT4", "RTTY", "SSTV", "JS8", "HELL", "PSK", "OLIVIA", "PKT", "MSK144"]
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"]
SSB_SUB_MODES = ["USB", "LSB"]
DV_SUB_MODES = ["DMR", "DSTAR", "C4FM", "FUSION", "M17"]
# Mode aliases. Sometimes we get spots with a mode described in a different way that is effectively the same as a mode # Mode aliases. Sometimes we get spots with a mode described in a different way that is effectively the same as a mode
# we already know, or we want to normalise things for consistency. The lookup table for this is here. Incoming spots # we already know, or we want to normalise things for consistency. The lookup table for this is here. Incoming spots
-1
View File
@@ -18,7 +18,6 @@ from core.cache_utils import SEMI_STATIC_URL_DATA_CACHE
from core.config import config from core.config import config
from core.constants import BANDS, UNKNOWN_BAND, CW_MODES, PHONE_MODES, DATA_MODES, ALL_MODES, \ from core.constants import BANDS, UNKNOWN_BAND, CW_MODES, PHONE_MODES, DATA_MODES, ALL_MODES, \
HTTP_HEADERS, HAMQTH_PRG, MODE_ALIASES HTTP_HEADERS, HAMQTH_PRG, MODE_ALIASES
from data.lookup_credentials import LookupCredentials
# QRZ XML field names differ from pyhamtools' normalised names; map them here. # QRZ XML field names differ from pyhamtools' normalised names; map them here.
_QRZ_FIELD_MAP = { _QRZ_FIELD_MAP = {
+9 -3
View File
@@ -14,7 +14,6 @@ from core.constants import MODE_ALIASES
from core.geo_utils import lat_lon_to_cq_zone, lat_lon_to_itu_zone from core.geo_utils import lat_lon_to_cq_zone, lat_lon_to_itu_zone
from core.lookup_helper import lookup_helper, infer_band_from_freq, infer_mode_from_comment, \ from core.lookup_helper import lookup_helper, infer_band_from_freq, infer_mode_from_comment, \
infer_mode_from_frequency, infer_mode_type_from_mode infer_mode_from_frequency, infer_mode_type_from_mode
from data.lookup_credentials import LookupCredentials
from core.sig_utils import populate_sig_ref_info, ANY_SIG_REGEX, get_ref_regex_for_sig from core.sig_utils import populate_sig_ref_info, ANY_SIG_REGEX, get_ref_regex_for_sig
from data.sig_ref import SIGRef from data.sig_ref import SIGRef
@@ -252,9 +251,16 @@ class Spot:
if self.comment: if self.comment:
sig_matches = re.finditer(r"(^|\W)" + ANY_SIG_REGEX + r"($|\W)", self.comment, re.IGNORECASE) sig_matches = re.finditer(r"(^|\W)" + ANY_SIG_REGEX + r"($|\W)", self.comment, re.IGNORECASE)
for sig_match in sig_matches: for sig_match in sig_matches:
# First of all, if we haven't got a SIG for this spot set yet, now we have. This covers things like cluster # See what SIG we think this is
# spots where the comment is just "POTA".
found_sig = sig_match.group(2).upper() found_sig = sig_match.group(2).upper()
# "TOTA" is now ambiguous, with Toilets and Towers both using it. If we have found "TOTA" in a comment,
# ignore it as we can't tell what it is.
if found_sig != "TOTA":
continue
# Now, if we haven't got a SIG for this spot set yet, now we have. This covers things like cluster
# spots where the comment is just "POTA".
if not self.sig: if not self.sig:
self.sig = found_sig self.sig = found_sig
+95 -8
View File
@@ -1,12 +1,14 @@
import json import json
import logging import logging
import re import re
import threading
from datetime import datetime from datetime import datetime
import pytz import pytz
import requests
import tornado import tornado
from core.config import ALLOW_SPOTTING, MAX_SPOT_AGE from core.config import ALLOW_SPOTTING, ALLOW_UPSTREAM_SPOTTING, MAX_SPOT_AGE, RECAPTCHA_SECRET_KEY
from core.constants import UNKNOWN_BAND from core.constants import UNKNOWN_BAND
from core.lookup_helper import infer_band_from_freq from core.lookup_helper import infer_band_from_freq
from core.prometheus_metrics_handler import api_requests_counter from core.prometheus_metrics_handler import api_requests_counter
@@ -15,13 +17,16 @@ from core.utils import serialize_everything
from data.sig_ref import SIGRef from data.sig_ref import SIGRef
from data.spot import Spot from data.spot import Spot
RECAPTCHA_VERIFY_URL = "https://www.google.com/recaptcha/api/siteverify"
class APISpotHandler(tornado.web.RequestHandler): class APISpotHandler(tornado.web.RequestHandler):
"""API request handler for /api/v1/spot (POST)""" """API request handler for /api/v1/spot (POST)"""
def initialize(self, spots, web_server_metrics): def initialize(self, spots, web_server_metrics, spot_providers=None):
self._spots = spots self._spots = spots
self._web_server_metrics = web_server_metrics self._web_server_metrics = web_server_metrics
self._spot_providers = spot_providers or []
def post(self): def post(self):
try: try:
@@ -58,15 +63,43 @@ class APISpotHandler(tornado.web.RequestHandler):
self.set_header("Content-Type", "application/json") self.set_header("Content-Type", "application/json")
return return
# Read in the request body as JSON then convert to a Spot object # Read in the request body as JSON
json_spot = tornado.escape.json_decode(post_data) json_body = tornado.escape.json_decode(post_data)
spot = Spot(**json_spot)
# Extract fields relating to how we handle the spot, such as CAPTCHA and upstream submission. Remove these
# from the data so they don't accidentally end up in the spot object itself.
# todo: Better way of separating these out. Possible without API change or not?
submit_upstream = json_body.pop("submit_upstream", False)
upstream_provider_name = json_body.pop("upstream_provider", None)
upstream_credentials = json_body.pop("upstream_credentials", {})
captcha_token = json_body.pop("captcha_token", None)
# Verify CAPTCHA if required
if RECAPTCHA_SECRET_KEY:
if not captcha_token:
self.set_status(422)
self.write(json.dumps("Error - CAPTCHA token is required for spot submission.",
default=serialize_everything))
self.set_header("Cache-Control", "no-store")
self.set_header("Content-Type", "application/json")
return
if not self._verify_recaptcha(captcha_token):
self.set_status(422)
self.write(json.dumps("Error - CAPTCHA verification failed.",
default=serialize_everything))
self.set_header("Cache-Control", "no-store")
self.set_header("Content-Type", "application/json")
return
# Convert remaining fields to a Spot object
spot = Spot(**json_body)
# Converting to a spot object this way won't have coped with sig_ref objects, so fix that. (Would be nice to # Converting to a spot object this way won't have coped with sig_ref objects, so fix that. (Would be nice to
# redo this in a functional style) # redo this in a functional style)
if spot.sig_refs: if spot.sig and spot.sig_refs:
real_sig_refs = [] real_sig_refs = []
for dict_obj in spot.sig_refs: for dict_obj in spot.sig_refs:
dict_obj = {**dict_obj, "sig": spot.sig}
real_sig_refs.append(json.loads(json.dumps(dict_obj), object_hook=lambda d: SIGRef(**d))) real_sig_refs.append(json.loads(json.dumps(dict_obj), object_hook=lambda d: SIGRef(**d)))
spot.sig_refs = real_sig_refs spot.sig_refs = real_sig_refs
@@ -126,11 +159,45 @@ class APISpotHandler(tornado.web.RequestHandler):
self.set_header("Content-Type", "application/json") self.set_header("Content-Type", "application/json")
return return
# infer missing data, and add it to our database. # Reject upstream submission if not permitted
spot.source = "API" if submit_upstream and not ALLOW_UPSTREAM_SPOTTING:
self.set_status(403)
self.write(json.dumps("Error - this server does not allow upstream spot submission.",
default=serialize_everything))
self.set_header("Cache-Control", "no-store")
self.set_header("Content-Type", "application/json")
return
# Submit upstream if requested
upstream_warning = None
if submit_upstream and upstream_provider_name and spot.sig:
provider = self._find_provider(upstream_provider_name, spot.sig)
if provider:
try:
# Submit spot to the upstream provider
provider.submit_spot(spot, upstream_credentials)
# Trigger a re-poll after 1 second so the spot appears quickly
threading.Timer(1.0, lambda: provider.force_poll()).start()
except NotImplementedError as e:
upstream_warning = str(e)
except Exception as e:
logging.warning("Failed to submit spot upstream to " + upstream_provider_name + ": " + str(e))
upstream_warning = "Spot was saved locally but upstream submission to " + upstream_provider_name + " failed: " + str(
e)
else:
upstream_warning = "No enabled provider named '" + upstream_provider_name + "' supports upstream submission for " + spot.sig + " spots."
# If we successfully submitted the spot upstream, don't add it direct to Spothole, otherwise it will be a
# duplicate with what immediately comes back from the API. But if we weren't asked to send it upstream, or
# we were but it failed, we should still add it to our database anyway.
if not submit_upstream or upstream_warning:
spot.infer_missing() spot.infer_missing()
self._spots.add(spot.id, spot, expire=MAX_SPOT_AGE) self._spots.add(spot.id, spot, expire=MAX_SPOT_AGE)
if upstream_warning:
self.write(json.dumps("Warning - " + upstream_warning, default=serialize_everything))
self.set_status(201)
else:
self.write(json.dumps("OK", default=serialize_everything)) self.write(json.dumps("OK", default=serialize_everything))
self.set_status(201) self.set_status(201)
self.set_header("Cache-Control", "no-store") self.set_header("Cache-Control", "no-store")
@@ -142,3 +209,23 @@ class APISpotHandler(tornado.web.RequestHandler):
self.set_status(500) self.set_status(500)
self.set_header("Cache-Control", "no-store") self.set_header("Cache-Control", "no-store")
self.set_header("Content-Type", "application/json") self.set_header("Content-Type", "application/json")
def _find_provider(self, provider_name, sig):
"""Find an enabled provider by name that can submit spots for the given SIG."""
for p in self._spot_providers:
if p.enabled and p.name == provider_name and p.can_submit_spot(sig):
return p
return None
def _verify_recaptcha(self, token):
"""Verify a Google reCAPTCHA v2 token. Returns True if valid."""
try:
response = requests.post(RECAPTCHA_VERIFY_URL,
data={"secret": RECAPTCHA_SECRET_KEY, "response": token},
timeout=(5, 10))
return response.ok and response.json().get("success", False)
except Exception as e:
logging.warning("reCAPTCHA verification request failed: " + str(e))
return False
+1 -1
View File
@@ -143,7 +143,7 @@ class APIAlertsStreamHandler(tornado_eventsource.handler.EventSourceHandler):
if self._alert_queue not in self._sse_alert_queues: if self._alert_queue not in self._sse_alert_queues:
logging.error("Web server cleared up a queue of an active connection!") logging.error("Web server cleared up a queue of an active connection!")
self.close() self.close()
except: except Exception as e:
logging.warning("Exception in SSE callback, connection will be closed: %s", e, exc_info=True) logging.warning("Exception in SSE callback, connection will be closed: %s", e, exc_info=True)
self.close() self.close()
+13 -2
View File
@@ -13,9 +13,10 @@ from core.utils import serialize_everything
class APIOptionsHandler(tornado.web.RequestHandler): class APIOptionsHandler(tornado.web.RequestHandler):
"""API request handler for /api/v1/options""" """API request handler for /api/v1/options"""
def initialize(self, status_data, web_server_metrics): def initialize(self, status_data, web_server_metrics, spot_providers=None):
self._status_data = status_data self._status_data = status_data
self._web_server_metrics = web_server_metrics self._web_server_metrics = web_server_metrics
self._spot_providers = spot_providers or []
def get(self): def get(self):
# Metrics # Metrics
@@ -24,6 +25,15 @@ class APIOptionsHandler(tornado.web.RequestHandler):
self._web_server_metrics["status"] = "OK" self._web_server_metrics["status"] = "OK"
api_requests_counter.inc() api_requests_counter.inc()
# Build a map of SIG name -> list of provider names that can submit spots for that SIG
spot_submit_providers = {}
for provider in self._spot_providers:
if not provider.enabled:
continue
for sig in SIGS:
if provider.can_submit_spot(sig.name):
spot_submit_providers.setdefault(sig.name, []).append(provider.name)
options = {"bands": BANDS, options = {"bands": BANDS,
"modes": ALL_MODES, "modes": ALL_MODES,
"mode_types": MODE_TYPES, "mode_types": MODE_TYPES,
@@ -35,7 +45,8 @@ class APIOptionsHandler(tornado.web.RequestHandler):
map(lambda p: p["name"], filter(lambda p: p["enabled"], self._status_data["alert_providers"]))), map(lambda p: p["name"], filter(lambda p: p["enabled"], self._status_data["alert_providers"]))),
"continents": CONTINENTS, "continents": CONTINENTS,
"max_spot_age": MAX_SPOT_AGE, "max_spot_age": MAX_SPOT_AGE,
"spot_allowed": ALLOW_SPOTTING} "spot_allowed": ALLOW_SPOTTING,
"spot_submit_providers": spot_submit_providers}
# If spotting to this server is enabled, "API" is another valid spot source even though it does not come from # If spotting to this server is enabled, "API" is another valid spot source even though it does not come from
# one of our proviers. # one of our proviers.
if ALLOW_SPOTTING: if ALLOW_SPOTTING:
-2
View File
@@ -1,11 +1,9 @@
import json
from datetime import datetime from datetime import datetime
import pytz import pytz
import tornado import tornado
from core.prometheus_metrics_handler import api_requests_counter from core.prometheus_metrics_handler import api_requests_counter
from core.utils import serialize_everything
class APISolarConditionsHandler(tornado.web.RequestHandler): class APISolarConditionsHandler(tornado.web.RequestHandler):
+1 -1
View File
@@ -145,7 +145,7 @@ class APISpotsStreamHandler(tornado_eventsource.handler.EventSourceHandler):
if self._spot_queue not in self._sse_spot_queues: if self._spot_queue not in self._sse_spot_queues:
logging.error("Web server cleared up a queue of an active connection!") logging.error("Web server cleared up a queue of an active connection!")
self.close() self.close()
except: except Exception as e:
logging.warning("Exception in SSE callback, connection will be closed: %s", e, exc_info=True) logging.warning("Exception in SSE callback, connection will be closed: %s", e, exc_info=True)
self.close() self.close()
+4 -3
View File
@@ -22,7 +22,7 @@ from server.handlers.pagetemplate import PageTemplateHandler
class WebServer: class WebServer:
"""Provides the public-facing web server.""" """Provides the public-facing web server."""
def __init__(self, spots, alerts, solar_conditions, status_data): def __init__(self, spots, alerts, solar_conditions, status_data, spot_providers=None):
"""Constructor""" """Constructor"""
self._spots = spots self._spots = spots
@@ -31,6 +31,7 @@ class WebServer:
self._sse_spot_queues = [] self._sse_spot_queues = []
self._sse_alert_queues = [] self._sse_alert_queues = []
self._status_data = status_data self._status_data = status_data
self._spot_providers = spot_providers or []
self._port = WEB_SERVER_PORT self._port = WEB_SERVER_PORT
self._api_only_mode = API_ONLY_MODE self._api_only_mode = API_ONLY_MODE
self._shutdown_event = asyncio.Event() self._shutdown_event = asyncio.Event()
@@ -69,12 +70,12 @@ class WebServer:
{"sse_alert_queues": self._sse_alert_queues, **handler_opts}), {"sse_alert_queues": self._sse_alert_queues, **handler_opts}),
(r"/api/v1/solar", APISolarConditionsHandler, {"solar_conditions": self._solar_conditions, **handler_opts}), (r"/api/v1/solar", APISolarConditionsHandler, {"solar_conditions": self._solar_conditions, **handler_opts}),
(r"/api/v1/dxstats", APIDxStatsHandler, {"spots": self._spots, **handler_opts}), (r"/api/v1/dxstats", APIDxStatsHandler, {"spots": self._spots, **handler_opts}),
(r"/api/v1/options", APIOptionsHandler, {"status_data": self._status_data, **handler_opts}), (r"/api/v1/options", APIOptionsHandler, {"status_data": self._status_data, "spot_providers": self._spot_providers, **handler_opts}),
(r"/api/v1/status", APIStatusHandler, {"status_data": self._status_data, **handler_opts}), (r"/api/v1/status", APIStatusHandler, {"status_data": self._status_data, **handler_opts}),
(r"/api/v1/lookup/call", APILookupCallHandler, {**handler_opts}), (r"/api/v1/lookup/call", APILookupCallHandler, {**handler_opts}),
(r"/api/v1/lookup/sigref", APILookupSIGRefHandler, {**handler_opts}), (r"/api/v1/lookup/sigref", APILookupSIGRefHandler, {**handler_opts}),
(r"/api/v1/lookup/grid", APILookupGridHandler, {**handler_opts}), (r"/api/v1/lookup/grid", APILookupGridHandler, {**handler_opts}),
(r"/api/v1/spot", APISpotHandler, {"spots": self._spots, **handler_opts}), (r"/api/v1/spot", APISpotHandler, {"spots": self._spots, "spot_providers": self._spot_providers, **handler_opts}),
] ]
# If in API-only mode, serve a basic homepage; in normal mode, serve the usual UI routes # If in API-only mode, serve a basic homepage; in normal mode, serve the usual UI routes
+9 -6
View File
@@ -98,18 +98,21 @@ if __name__ == '__main__':
# Set up lookup helper # Set up lookup helper
lookup_helper.start() lookup_helper.start()
# Set up web server # Create spot providers
web_server = WebServer(spots=spots, alerts=alerts, solar_conditions=solar_conditions, status_data=status_data)
# Fetch, set up and start spot providers
for entry in config["spot-providers"]: for entry in config["spot-providers"]:
spot_providers.append(get_spot_provider_from_config(entry)) spot_providers.append(get_spot_provider_from_config(entry))
# Set up web server
web_server = WebServer(spots=spots, alerts=alerts, solar_conditions=solar_conditions, status_data=status_data,
spot_providers=spot_providers)
# Set up and start spot providers
for p in spot_providers: for p in spot_providers:
p.setup(spots=spots, web_server=web_server) p.setup(spots=spots, web_server=web_server)
if p.enabled: if p.enabled:
p.start() p.start()
# Fetch, set up and start alert providers # Create, set up and start alert providers
for entry in config["alert-providers"]: for entry in config["alert-providers"]:
alert_providers.append(get_alert_provider_from_config(entry)) alert_providers.append(get_alert_provider_from_config(entry))
for p in alert_providers: for p in alert_providers:
@@ -117,7 +120,7 @@ if __name__ == '__main__':
if p.enabled: if p.enabled:
p.start() p.start()
# Fetch, set up and start solar conditions providers # Create, set up and start solar conditions providers
for entry in config.get("solar-condition-providers", []): for entry in config.get("solar-condition-providers", []):
solar_condition_providers.append(get_solar_conditions_provider_from_config(entry)) solar_condition_providers.append(get_solar_conditions_provider_from_config(entry))
for p in solar_condition_providers: for p in solar_condition_providers:
+8
View File
@@ -89,3 +89,11 @@ class GMA(HTTPSpotProvider):
logging.warning("Exception when looking up " + self.REF_INFO_URL_ROOT + source_spot[ logging.warning("Exception when looking up " + self.REF_INFO_URL_ROOT + source_spot[
"REF"] + ", ignoring this spot for now") "REF"] + ", ignoring this spot for now")
return new_spots return new_spots
def can_submit_spot(self, sig):
return sig == "GMA"
def submit_spot(self, spot, credentials):
# TODO: Implement.
# Spotting to GMA is documented: https://www.cqgma.org/api/doc/apigma_spot.pdf We (or the user) need a GMA account, and to send the password in plaintext(!!)
raise NotImplementedError("GMA upstream spot submission is not yet implemented")
+7
View File
@@ -64,3 +64,10 @@ class HEMA(HTTPSpotProvider):
# that for us. # that for us.
new_spots.append(spot) new_spots.append(spot)
return new_spots return new_spots
def can_submit_spot(self, sig):
return sig == "HEMA"
def submit_spot(self, spot, credentials):
# TODO: Implement. Spotting to HEMA is covered in the original email from the team.
raise NotImplementedError("HEMA upstream spot submission is not yet implemented")
+10 -1
View File
@@ -19,6 +19,7 @@ class HTTPSpotProvider(SpotProvider):
self._poll_interval = poll_interval self._poll_interval = poll_interval
self._thread = None self._thread = None
self._stop_event = Event() self._stop_event = Event()
self._wakeup_event = Event()
def start(self): def start(self):
# Fire off the polling thread. It will poll immediately on startup, then sleep for poll_interval between # Fire off the polling thread. It will poll immediately on startup, then sleep for poll_interval between
@@ -29,11 +30,19 @@ class HTTPSpotProvider(SpotProvider):
def stop(self): def stop(self):
self._stop_event.set() self._stop_event.set()
self._wakeup_event.set()
def force_poll(self):
"""Trigger an immediate poll without waiting for the normal interval."""
self._wakeup_event.set()
def _run(self): def _run(self):
while True: while True:
self._wakeup_event.clear()
self._poll() self._poll()
if self._stop_event.wait(timeout=self._poll_interval): self._wakeup_event.wait(timeout=self._poll_interval)
if self._stop_event.is_set():
break break
def _poll(self): def _poll(self):
+28
View File
@@ -3,7 +3,9 @@ import re
from datetime import datetime from datetime import datetime
import pytz import pytz
import requests
from core.constants import HTTP_HEADERS
from data.sig_ref import SIGRef from data.sig_ref import SIGRef
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,7 +16,9 @@ class ParksNPeaks(HTTPSpotProvider):
POLL_INTERVAL_SEC = 120 POLL_INTERVAL_SEC = 120
SPOTS_URL = "https://www.parksnpeaks.org/api/ALL" SPOTS_URL = "https://www.parksnpeaks.org/api/ALL"
SUBMIT_URL = "https://www.parksnpeaks.org/api/SPOT/"
SIOTA_LIST_URL = "https://www.silosontheair.com/data/silos.csv" SIOTA_LIST_URL = "https://www.silosontheair.com/data/silos.csv"
SUBMITTABLE_SIGS = ["POTA", "SOTA", "WWFF", "HEMA", "WOTA", "ZLOTA", "SIOTA", "KRMNPA"]
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)
@@ -62,3 +66,27 @@ class ParksNPeaks(HTTPSpotProvider):
# Add new spot to the list # Add new spot to the list
new_spots.append(spot) new_spots.append(spot)
return new_spots return new_spots
def can_submit_spot(self, sig):
return sig in self.SUBMITTABLE_SIGS
def submit_spot(self, spot, credentials):
# TODO test this works
user_id = credentials.get("user_id", "")
api_key = credentials.get("api_key", "")
if not user_id or not api_key:
raise ValueError("Parks N Peaks user ID and API key are required. Get yours from your Parks N Peaks account.")
sig_ref = spot.sig_refs[0].id if spot.sig_refs else ""
body = {
"actClass": spot.sig or "",
"actCallsign": spot.dx_call,
"actSite": sig_ref,
"mode": spot.mode or "",
"freq": str(spot.freq / 1000000.0),
"comments": spot.comment or "",
"userID": user_id,
"APIKey": api_key,
}
response = requests.post(self.SUBMIT_URL, json=body, headers=HTTP_HEADERS, timeout=(5, 30))
if not response.ok:
raise RuntimeError("Parks N Peaks API returned " + str(response.status_code) + ": " + response.text)
+25
View File
@@ -1,7 +1,9 @@
from datetime import datetime from datetime import datetime
import pytz import pytz
import requests
from core.constants import HTTP_HEADERS
from data.sig_ref import SIGRef from data.sig_ref import SIGRef
from data.spot import Spot from data.spot import Spot
from spotproviders.http_spot_provider import HTTPSpotProvider from spotproviders.http_spot_provider import HTTPSpotProvider
@@ -12,6 +14,7 @@ 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"
SUBMIT_URL = "https://api.pota.app/spot"
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)
@@ -40,3 +43,25 @@ class POTA(HTTPSpotProvider):
# that for us. # that for us.
new_spots.append(spot) new_spots.append(spot)
return new_spots return new_spots
def can_submit_spot(self, sig):
return sig == "POTA"
def submit_spot(self, spot, credentials):
sig_ref = spot.sig_refs[0].id if spot.sig_refs else None
if sig_ref:
body = {
"activator": spot.dx_call,
"spotter": spot.de_call,
"frequency": str(spot.freq / 1000.0),
"mode": spot.mode or "",
"reference": sig_ref,
"comments": spot.comment or "",
"source": "Spothole",
}
headers = {**HTTP_HEADERS, "Content-Type": "application/json"}
response = requests.post(self.SUBMIT_URL, json=body, headers=headers, timeout=(5, 30))
if not response.ok:
raise RuntimeError("POTA API returned " + str(response.status_code) + ": " + response.text)
else:
raise RuntimeError("Park reference is required for submitting POTA spots.")
+46 -1
View File
@@ -2,7 +2,7 @@ from datetime import datetime
import requests import requests
from core.constants import HTTP_HEADERS from core.constants import HTTP_HEADERS, SSB_SUB_MODES, DV_SUB_MODES
from data.sig_ref import SIGRef from data.sig_ref import SIGRef
from data.spot import Spot from data.spot import Spot
from spotproviders.http_spot_provider import HTTPSpotProvider from spotproviders.http_spot_provider import HTTPSpotProvider
@@ -20,6 +20,9 @@ class SOTA(HTTPSpotProvider):
# SOTA spots don't contain lat/lon, we need a separate lookup for that # SOTA spots don't contain lat/lon, we need a separate lookup for that
SUMMIT_URL_ROOT = "https://api-db2.sota.org.uk/api/summits/" SUMMIT_URL_ROOT = "https://api-db2.sota.org.uk/api/summits/"
SUBMIT_URL = "https://api-db2.sota.org.uk/api/spots"
VALID_MODES = ["AM", "CW", "Data", "DV", "FM", "SSB"]
def __init__(self, provider_config): def __init__(self, provider_config):
super().__init__(provider_config, self.EPOCH_URL, self.POLL_INTERVAL_SEC) super().__init__(provider_config, self.EPOCH_URL, self.POLL_INTERVAL_SEC)
self._api_epoch = "" self._api_epoch = ""
@@ -56,3 +59,45 @@ class SOTA(HTTPSpotProvider):
# that for us. # that for us.
new_spots.append(spot) new_spots.append(spot)
return new_spots return new_spots
def can_submit_spot(self, sig):
return sig == "SOTA"
def submit_spot(self, spot, credentials):
# TODO test this method works
access_token = credentials.get("access_token", "")
id_token = credentials.get("id_token", "")
if not access_token or not id_token:
raise ValueError("SOTA API tokens are required. Please log into SOTA in order to spot to it.")
sig_ref = spot.sig_refs[0].id if spot.sig_refs else ""
if sig_ref:
# Split reference into association and summit codes
ref_split = sig_ref.split("/")
# Figure out a valid mode. Borrowed this from PoLo :)
# https://github.com/ham2k/app-polo/blob/main/src/extensions/activities/sota/SOTAPostSelfSpot.js
mode = spot.mode
if mode and mode not in self.VALID_MODES:
if mode in SSB_SUB_MODES:
mode = "SSB"
elif mode in DV_SUB_MODES:
mode = "DV"
else:
mode = "Data"
body = {
"activatorCallsign": spot.dx_call,
"associationCode": ref_split[0],
"summitCode": ref_split[1],
"frequency": spot.freq / 1000000.0,
"mode": mode or "",
"callsign": spot.de_call,
"comments": spot.comment or "",
"type": "TEST" # todo replatce with NORMAL/QRT once testing complete
}
headers = {**HTTP_HEADERS, "Authorization": "bearer " + access_token, "id_token": id_token, "Content-Type": "application/json"}
response = requests.post(self.SUBMIT_URL, json=body, headers=headers, timeout=(5, 30))
if not response.ok:
raise RuntimeError("SOTA API returned " + str(response.status_code) + ": " + response.text)
else:
raise RuntimeError("Summit reference is required for submitting SOTA spots.")
+17
View File
@@ -68,3 +68,20 @@ class SpotProvider:
"""Stop any threads and prepare for application shutdown""" """Stop any threads and prepare for application shutdown"""
raise NotImplementedError("Subclasses must implement this method") raise NotImplementedError("Subclasses must implement this method")
def can_submit_spot(self, sig):
"""Return True if this provider supports submitting spots upstream for the given SIG."""
return False
def submit_spot(self, spot, credentials):
"""Submit a spot upstream to this provider's API. credentials is a dict with provider-specific keys.
Raises an exception with a descriptive message on failure."""
raise NotImplementedError("This provider does not support spot submission")
def force_poll(self):
"""Trigger an immediate poll without waiting for the normal interval. Default implementation here does nothing
because not all spot providers have a polling mechanism. Providers that do should override this method."""
return
+47
View File
@@ -1,5 +1,8 @@
from datetime import datetime from datetime import datetime
import requests
from core.constants import HTTP_HEADERS, SSB_SUB_MODES
from data.sig_ref import SIGRef from data.sig_ref import SIGRef
from data.spot import Spot from data.spot import Spot
from spotproviders.http_spot_provider import HTTPSpotProvider from spotproviders.http_spot_provider import HTTPSpotProvider
@@ -10,6 +13,8 @@ class Tiles(HTTPSpotProvider):
POLL_INTERVAL_SEC = 120 POLL_INTERVAL_SEC = 120
SPOTS_URL = "https://icneuzxitdqtofutxbla.supabase.co/functions/v1/spots?active_hours=24" SPOTS_URL = "https://icneuzxitdqtofutxbla.supabase.co/functions/v1/spots?active_hours=24"
SUBMIT_URL = "https://icneuzxitdqtofutxbla.supabase.co/functions/v1/self-spot"
VALID_MODES = ["SSB", "CW", "FT8", "FT4", "FM", "DMR", "D-STAR", "M17", "AX.25", "JS8Call", "PSK31", "Olivia", "VarAC", "Other"]
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)
@@ -41,6 +46,48 @@ class Tiles(HTTPSpotProvider):
new_spots.append(spot) new_spots.append(spot)
return new_spots return new_spots
def can_submit_spot(self, sig):
return sig == "Tiles"
def submit_spot(self, spot, credentials):
# Tiles on the air currently only supports *self* spots
if spot.dx_call == spot.de_call:
# Figure out a valid mode. Borrowed this from PoLo :)
# https://github.com/ham2k/app-polo/blob/main/src/extensions/activities/sota/SOTAPostSelfSpot.js
if spot.mode:
mode = spot.mode
if mode not in self.VALID_MODES:
if mode in SSB_SUB_MODES:
mode = "SSB"
elif mode == "OLIVIA":
mode = "Olivia"
elif mode == "JS8":
mode = "JS8Call"
else:
mode = "Other"
body = {
"call_sign": spot.dx_call,
"frequency": str(spot.freq / 1000000.0),
"mode": mode or "",
"grid": spot.dx_grid or "",
"comment": spot.comment or "",
"lat": spot.dx_latitude or None,
"lon": spot.dx_longitude or None,
"qrt": spot.qrt or False,
"pin": credentials.get("offline_spot_gateway_pin", "")
}
headers = {**HTTP_HEADERS, "Content-Type": "application/json"}
response = requests.post(self.SUBMIT_URL, json=body, headers=headers, timeout=(5, 30))
if not response.ok:
raise RuntimeError("Tiles on the Air API returned " + str(response.status_code) + ": " + response.text)
else:
raise RuntimeError("The Tiles on the Air API requires a mode to be set.")
else:
raise RuntimeError("The Tiles on the Air API only supports self-spots, the DX call and spotter call must match.")
# Utility function to keep the first decimal point in a given string but remove any others. Used to parse Tiles' # Utility function to keep the first decimal point in a given string but remove any others. Used to parse Tiles'
# strange frequency format where we can sometimes have e.g. "14.123.5". # strange frequency format where we can sometimes have e.g. "14.123.5".
def strip_extra_decimal_points(s): def strip_extra_decimal_points(s):
+7
View File
@@ -77,3 +77,10 @@ class WOTA(HTTPSpotProvider):
except Exception as e: except Exception as e:
logging.error("Exception parsing WOTA spot", e) logging.error("Exception parsing WOTA spot", e)
return new_spots return new_spots
def can_submit_spot(self, sig):
return sig == "WOTA"
def submit_spot(self, spot, credentials):
# TODO Ask M5TEA if he's happy to share how this is done from his app
raise NotImplementedError("WOTA upstream spot submission is not yet implemented")
+7
View File
@@ -41,3 +41,10 @@ class WWBOTA(SSESpotProvider):
# WWBOTA does support a special "Test" spot type, we need to avoid adding that. # WWBOTA does support a special "Test" spot type, we need to avoid adding that.
return spot if source_spot["type"] != "Test" else None return spot if source_spot["type"] != "Test" else None
def can_submit_spot(self, sig):
return sig == "WWBOTA"
def submit_spot(self, spot, credentials):
# TODO: Implement. WWBOTA API docs cover this: https://api.wwbota.org/#tag/Spots/operation/create_spot_spots__post
raise NotImplementedError("WWBOTA upstream spot submission is not yet implemented")
+7
View File
@@ -38,3 +38,10 @@ class WWFF(HTTPSpotProvider):
# that for us. # that for us.
new_spots.append(spot) new_spots.append(spot)
return new_spots return new_spots
def can_submit_spot(self, sig):
return sig == "WWFF"
def submit_spot(self, spot, credentials):
# TODO: Implement. Spotting to WWFF should be possible, need to look up the Spotline docs or copy approach from PoLo. Either way I think we need an API key for the app (but maybe not for the user?)
raise NotImplementedError("WWFF upstream spot submission is not yet implemented")
+7
View File
@@ -41,3 +41,10 @@ class ZLOTA(HTTPSpotProvider):
new_spots.append(spot) new_spots.append(spot)
return new_spots return new_spots
def can_submit_spot(self, sig):
return sig == "ZLOTA"
def submit_spot(self, spot, credentials):
# TODO: Implement. Spotting to ZLOTA is supported via POST, see https://ontheair.nz/api
raise NotImplementedError("ZLOTA upstream spot submission is not yet implemented")
-1
View File
@@ -69,7 +69,6 @@
<p>This software is dedicated to the memory of Tom G1PJB, SK, a friend and colleague who sadly passed away around the time I started writing it in Autumn 2025. I was looking forward to showing it to you when it was done.</p> <p>This software is dedicated to the memory of Tom G1PJB, SK, a friend and colleague who sadly passed away around the time I started writing it in Autumn 2025. I was looking forward to showing it to you when it was done.</p>
</div> </div>
<script src="/js/common.js?v=1780999608"></script>
<script>$(document).ready(function() { $("#nav-link-about").addClass("active"); }); <!-- highlight active page in nav --></script> <script>$(document).ready(function() { $("#nav-link-about").addClass("active"); }); <!-- highlight active page in nav --></script>
{% end %} {% end %}
+1 -2
View File
@@ -69,8 +69,7 @@
</div> </div>
<script src="/js/common.js?v=1780999608"></script> <script src="/js/add-spot.js?v=1781811406"></script>
<script src="/js/add-spot.js?v=1780999608"></script>
<script>$(document).ready(function() { $("#nav-link-add-spot").addClass("active"); }); <!-- highlight active page in nav --></script> <script>$(document).ready(function() { $("#nav-link-add-spot").addClass("active"); }); <!-- highlight active page in nav --></script>
{% end %} {% end %}
+1 -2
View File
@@ -70,8 +70,7 @@
</div> </div>
<script src="/js/common.js?v=1780999608"></script> <script src="/js/alerts.js?v=1781811406"></script>
<script src="/js/alerts.js?v=1780999608"></script>
<script>$(document).ready(function() { $("#nav-link-alerts").addClass("active"); }); <!-- highlight active page in nav --></script> <script>$(document).ready(function() { $("#nav-link-alerts").addClass("active"); }); <!-- highlight active page in nav --></script>
{% end %} {% end %}
+1 -2
View File
@@ -1,7 +1,6 @@
{% extends "skeleton.html" %} {% extends "skeleton.html" %}
{% block head_extra %} {% block head_extra %}
<link href="https://cdn.jsdelivr.net/npm/bootstrap@5.3.8/dist/css/bootstrap.min.css" rel="stylesheet" <link href="/vendor/css/bootstrap-5.3.8.min.css" rel="stylesheet">
integrity="sha384-sRIl4kxILFvY47J16cr9ZwB07vP4J8+LH7qKQnuqkuIAvNWLzeN8tE5YBujZqJLB" crossorigin="anonymous">
{% end %} {% end %}
{% block body %} {% block body %}
<div class="container mt-5"> <div class="container mt-5">
+2 -3
View File
@@ -76,9 +76,8 @@
<script> <script>
let spotProvidersEnabledByDefault = {% raw json_encode(web_ui_options["spot-providers-enabled-by-default"]) %}; let spotProvidersEnabledByDefault = {% raw json_encode(web_ui_options["spot-providers-enabled-by-default"]) %};
</script> </script>
<script src="/js/common.js?v=1780999608"></script> <script src="/js/spotsbandsandmap.js?v=1781811406"></script>
<script src="/js/spotsbandsandmap.js?v=1780999608"></script> <script src="/js/bands.js?v=1781811406"></script>
<script src="/js/bands.js?v=1780999608"></script>
<script>$(document).ready(function() { $("#nav-link-bands").addClass("active"); }); <!-- highlight active page in nav --></script> <script>$(document).ready(function() { $("#nav-link-bands").addClass("active"); }); <!-- highlight active page in nav --></script>
{% end %} {% end %}
+12 -20
View File
@@ -1,27 +1,19 @@
{% extends "skeleton.html" %} {% extends "skeleton.html" %}
{% block head_extra %} {% block head_extra %}
<link rel="stylesheet" href="/css/style.css?v=1780999608" type="text/css"> <link rel="stylesheet" href="/css/style.css?v=1781811406" type="text/css">
<link href="https://cdn.jsdelivr.net/npm/bootstrap@5.3.8/dist/css/bootstrap.min.css" rel="stylesheet" <link href="/vendor/css/bootstrap-5.3.8.min.css" rel="stylesheet">
integrity="sha384-sRIl4kxILFvY47J16cr9ZwB07vP4J8+LH7qKQnuqkuIAvNWLzeN8tE5YBujZqJLB" crossorigin="anonymous"> <link href="/vendor/css/fontawesome-6.7.2.min.css" rel="stylesheet">
<link href="/fa/css/fontawesome.min.css" rel="stylesheet" /> <link href="/vendor/css/solid-6.7.2.min.css" rel="stylesheet">
<link href="/fa/css/solid.min.css" rel="stylesheet" />
<script src="https://cdn.jsdelivr.net/npm/jquery@3.7.1/dist/jquery.min.js" <script src="/vendor/js/jquery-3.7.1.min.js"></script>
integrity="sha384-1H217gwSVyLSIfaLxHbE7dRb3v4mYCKbpQvzx0cegeju1MVsGrX5xXxAvs/HgeFs" <script src="/vendor/js/moment-2.29.4.min.js"></script>
crossorigin="anonymous"></script> <script src="/vendor/js/bootstrap-5.3.8.bundle.min.js"></script>
<script src="https://cdn.jsdelivr.net/npm/moment@2.29.4/moment.min.js" <script src="/vendor/js/tinycolor2-1.6.0.min.js"></script>
integrity="sha384-N1xdnJwBzqfCpEDxEeSQzv4NPVPViBQq2NLbzth3YA1pLvR9mtf+TV5g6O+KLkPY"
crossorigin="anonymous"></script>
<script src="https://cdn.jsdelivr.net/npm/bootstrap@5.3.8/dist/js/bootstrap.bundle.min.js"
integrity="sha384-FKyoEForCGlyvwx9Hj09JcYn3nv7wiPVlz7YYwJrWVcXK/BmnVDxM+D2scQbITxI"
crossorigin="anonymous"></script>
<script src="https://cdn.jsdelivr.net/npm/tinycolor2@1.6.0/cjs/tinycolor.min.js"
integrity="sha384-L1eE4eD41kpBIWe2I0eHy+GnEUC4RIpcvibVW2JCminuPlTl+2Bc528iPdVMg5Dn"
crossorigin="anonymous"></script>
<script src="https://misc.ianrenton.com/jsutils/utils.js?v=1780999608"></script> <script src="/js/utils.js?v=1781811406"></script>
<script src="https://misc.ianrenton.com/jsutils/ui-ham.js?v=1780999608"></script> <script src="/js/ui-ham.js?v=1781811406"></script>
<script src="https://misc.ianrenton.com/jsutils/geo.js?v=1780999608"></script> <script src="/js/geo.js?v=1781811406"></script>
<script src="/js/common.js?v=1781811406"></script>
{% end %} {% end %}
{% block body %} {% block body %}
<div class="container"> <div class="container">
+2 -3
View File
@@ -283,9 +283,8 @@
</div> </div>
</div> </div>
<script src="https://cdn.jsdelivr.net/npm/chart.js@4.4.9/dist/chart.umd.min.js"></script> <script src="/vendor/js/chart-4.4.9.umd.min.js"></script>
<script src="/js/common.js?v=1780999608"></script> <script src="/js/conditions.js?v=1781811406"></script>
<script src="/js/conditions.js?v=1780999608"></script>
<script>$(document).ready(function () { <script>$(document).ready(function () {
$("#nav-link-conditions").addClass("active"); $("#nav-link-conditions").addClass("active");
}); <!-- highlight active page in nav --></script> }); <!-- highlight active page in nav --></script>
+16 -17
View File
@@ -76,27 +76,26 @@
</div> </div>
</div> </div>
<link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/leaflet@1.9.4/dist/leaflet.min.css"> <link rel="stylesheet" href="/vendor/css/leaflet-1.9.4.min.css">
<link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/leaflet-extra-markers@1.2.2/dist/css/leaflet.extra-markers.min.css"> <link rel="stylesheet" href="/vendor/css/leaflet-extra-markers-1.2.2.min.css">
<script src="https://cdn.jsdelivr.net/npm/leaflet@1.9.4/dist/leaflet.min.js"></script> <script src="/vendor/js/leaflet-1.9.4.min.js"></script>
<script src="https://cdn.jsdelivr.net/npm/overlapping-marker-spiderfier-leaflet/dist/oms.min.js"></script> <script src="/vendor/js/oms-leaflet-0.2.7.min.js"></script>
<script src="https://cdn.jsdelivr.net/npm/leaflet-providers@2.0.0/leaflet-providers.min.js"></script> <script src="/vendor/js/leaflet-providers-2.0.0.js"></script>
<script src="https://cdn.jsdelivr.net/npm/leaflet-extra-markers@1.2.2/src/assets/js/leaflet.extra-markers.min.js" type="module"></script> <script src="/vendor/js/leaflet-extra-markers-1.2.2.min.js"></script>
<script src="https://cdn.jsdelivr.net/npm/leaflet.geodesic"></script> <script src="/vendor/js/leaflet-geodesic-2.7.2.umd.min.js"></script>
<script src="https://unpkg.com/leaflet.vectorgrid@latest/dist/Leaflet.VectorGrid.js"></script> <script src="/vendor/js/leaflet-vectorgrid-1.3.0.js"></script>
<script src="https://cdn.jsdelivr.net/npm/text-image/dist/text-image.js"></script> <script src="/vendor/js/text-image-0.7.0.js"></script>
<script src="https://cdn.jsdelivr.net/npm/@joergdietrich/leaflet.terminator@1.1.0/L.Terminator.min.js"></script> <script src="/vendor/js/leaflet-terminator-1.1.0.min.js"></script>
<script src="https://ianrenton.github.io/Leaflet.Maidenhead/src/L.Maidenhead.js"></script> <script src="/vendor/js/leaflet-maidenhead.js"></script>
<script src="https://ha8tks.github.io/Leaflet.ITUzones/src/L.ITUzones.js"></script> <script src="/vendor/js/leaflet-ituzones.js"></script>
<script src="https://ha8tks.github.io/Leaflet.CQzones/src/L.CQzones.js"></script> <script src="/vendor/js/leaflet-cqzones.js"></script>
<script src="https://misc.ianrenton.com/Leaflet.WorkedAllBritainIreland/L.WorkedAllBritainIreland.js"></script> <script src="/vendor/js/leaflet-workedallbritainireland.js"></script>
<script> <script>
let spotProvidersEnabledByDefault = {% raw json_encode(web_ui_options["spot-providers-enabled-by-default"]) %}; let spotProvidersEnabledByDefault = {% raw json_encode(web_ui_options["spot-providers-enabled-by-default"]) %};
</script> </script>
<script src="/js/common.js?v=1780999608"></script> <script src="/js/spotsbandsandmap.js?v=1781811406"></script>
<script src="/js/spotsbandsandmap.js?v=1780999608"></script> <script src="/js/map.js?v=1781811406"></script>
<script src="/js/map.js?v=1780999608"></script>
<script>$(document).ready(function() { $("#nav-link-map").addClass("active"); }); <!-- highlight active page in nav --></script> <script>$(document).ready(function() { $("#nav-link-map").addClass("active"); }); <!-- highlight active page in nav --></script>
{% end %} {% end %}
+2 -3
View File
@@ -104,9 +104,8 @@
<script> <script>
let spotProvidersEnabledByDefault = {% raw json_encode(web_ui_options["spot-providers-enabled-by-default"]) %}; let spotProvidersEnabledByDefault = {% raw json_encode(web_ui_options["spot-providers-enabled-by-default"]) %};
</script> </script>
<script src="/js/common.js?v=1780999608"></script> <script src="/js/spotsbandsandmap.js?v=1781811406"></script>
<script src="/js/spotsbandsandmap.js?v=1780999608"></script> <script src="/js/spots.js?v=1781811406"></script>
<script src="/js/spots.js?v=1780999608"></script>
<script>$(document).ready(function() { $("#nav-link-spots").addClass("active"); }); <!-- highlight active page in nav --></script> <script>$(document).ready(function() { $("#nav-link-spots").addClass("active"); }); <!-- highlight active page in nav --></script>
{% end %} {% end %}
+1 -2
View File
@@ -59,8 +59,7 @@
</div> </div>
</div> </div>
<script src="/js/common.js?v=1780999608"></script> <script src="/js/status.js?v=1781811406"></script>
<script src="/js/status.js?v=1780999608"></script>
<script> <script>
$(document).ready(function() { $("#nav-link-status").addClass("active"); }); <!-- highlight active page in nav --> $(document).ready(function() { $("#nav-link-status").addClass("active"); }); <!-- highlight active page in nav -->
</script> </script>
+84 -23
View File
@@ -15,6 +15,12 @@ info:
## Changelog ## Changelog
### 1.4
* POST `/spot` now supports upstream submission to external providers such as POTA and SOTA via new `submit_upstream`, `upstream_provider`, and `upstream_credentials` request body fields.
* POST `/spot` now supports Google reCaptcha and (if the site owner has set it up) now requires `captcha_token` in order to successfully submit. (This is used to lock down the submit function and prevent submission via Spothole by bots or third-party clients.)
* GET `/options` now returns `spot_submit_providers`, a map of SIG names to the names of providers that support upstream spot submission for that SIG.
### 1.3 ### 1.3
* `/solar` response now includes `ionosonde_data`, which contains ionosonde station measurements (LUF, foF2 and MUF) sourced from the GIRO Data Center as well as implied band states. * `/solar` response now includes `ionosonde_data`, which contains ionosonde station measurements (LUF, foF2 and MUF) sourced from the GIRO Data Center as well as implied band states.
@@ -36,7 +42,7 @@ info:
license: license:
name: The Unlicense name: The Unlicense
url: https://unlicense.org/#the-unlicense url: https://unlicense.org/#the-unlicense
version: v1.3 version: 1.4
servers: servers:
- url: https://spothole.app/api/v1 - url: https://spothole.app/api/v1
@@ -288,7 +294,8 @@ paths:
content: content:
application/json: application/json:
schema: schema:
$ref: '#/components/schemas/ErrorResponse' type: string
example: "Failed"
/lookup/sigref: /lookup/sigref:
@@ -313,7 +320,8 @@ paths:
content: content:
application/json: application/json:
schema: schema:
$ref: '#/components/schemas/ErrorResponse' type: string
example: "Failed"
@@ -339,7 +347,8 @@ paths:
content: content:
application/json: application/json:
schema: schema:
$ref: '#/components/schemas/ErrorResponse' type: string
example: "Failed"
/spot: /spot:
@@ -347,40 +356,44 @@ paths:
tags: tags:
- Spots - Spots
summary: Add a spot summary: Add a spot
description: "Supply a new spot object, which will be added to the system. Currently, this will not be reported up the chain to a cluster, POTA, SOTA etc. This may be introduced in a future version. cURL example: `curl --request POST --header \"Content-Type: application/json\" --data '{\"dx_call\":\"M0TRT\",\"time\":1760019539, \"freq\":14200000, \"comment\":\"Test spot please ignore\", \"de_call\":\"M0TRT\"}' https://spothole.app/api/v1/spot`" description: "Supply a new spot object, which will be added to the system. Optionally, set `submit_upstream` to true to forward the spot to an external provider such as POTA or SOTA. Check `spot_submit_providers` in the `/options` response to see which SIGs and providers support this. cURL example (local-only): `curl --request POST --header \"Content-Type: application/json\" --data '{\"dx_call\":\"M0TRT\",\"time\":1760019539, \"freq\":14200000, \"comment\":\"Test spot please ignore\", \"de_call\":\"M0TRT\"}' https://spothole.app/api/v1/spot`"
operationId: spot operationId: spot
requestBody: requestBody:
description: The JSON spot object description: The JSON spot object, plus optional upstream submission control fields
required: true required: true
content: content:
application/json: application/json:
schema: schema:
$ref: '#/components/schemas/Spot' $ref: '#/components/schemas/SpotSubmission'
responses: responses:
'200': '201':
description: Success description: Success
content: content:
application/json: application/json:
schema: schema:
$ref: '#/components/schemas/OkResponse' type: string
example: "OK"
'415': '415':
description: Incorrect Content-Type description: Incorrect Content-Type
content: content:
application/json: application/json:
schema: schema:
$ref: '#/components/schemas/ErrorResponse' type: string
example: "Failed"
'422': '422':
description: Validation error description: Validation error
content: content:
application/json: application/json:
schema: schema:
$ref: '#/components/schemas/ErrorResponse' type: string
example: "Failed"
'500': '500':
description: Internal server error description: Internal server error
content: content:
application/json: application/json:
schema: schema:
$ref: '#/components/schemas/ErrorResponse' type: string
example: "Failed"
components: components:
parameters: parameters:
@@ -982,6 +995,48 @@ components:
example: "GUID-123456" example: "GUID-123456"
SpotSubmission:
description: >
Request body for POST /spot. Contains all the fields of a Spot, plus optional
upstream submission control fields that are consumed by the server and never stored in the spot.
allOf:
- $ref: '#/components/schemas/Spot'
- type: object
properties:
submit_upstream:
type: boolean
description: >
If true, forward the spot to an external upstream provider (e.g. POTA, SOTA) rather
than only adding it to this Spothole server. Requires `sig`, at least one `sig_refs`
entry, and `upstream_provider` to be set. Check `spot_submit_providers` in the
/options response to see which SIGs and providers support this.
default: false
upstream_provider:
type: string
description: >
Name of the upstream provider to submit the spot to, e.g. "POTA" or "SOTA". Must
match one of the provider names returned in `spot_submit_providers` for the chosen SIG.
example: POTA
upstream_credentials:
type: object
description: >
Provider-specific credentials required to authenticate the upstream submission.
The required keys depend on the provider . Credentials are used only for the upstream
call and are never stored by Spothole.
additionalProperties:
type: string
example:
user_id: "12345"
api_key: "abc123"
captcha_token:
type: string
description: >
A Google reCAPTCHA v2 response token. Required when submitting upstream if the
server has reCAPTCHA configured (i.e. `submit_upstream` is true and the server
operator has set up reCAPTCHA keys). Obtain the token by completing the reCAPTCHA
widget rendered on the Add Spot page.
example: "03AFY_a8Xq..."
SpotStream: SpotStream:
type: object type: object
description: A server-sent event containing a spot description: A server-sent event containing a spot
@@ -1294,7 +1349,7 @@ components:
solar_storm_forecast: solar_storm_forecast:
type: object type: object
description: > description: >
NOAA Solar Radiation Storm forecast probability (%) of S1 or greater events per day. NOAA Solar Radiation Storm forecast containing probability (%) of S1 or greater events per day.
Keys are UNIX timestamps (UTC seconds since epoch) for the start of each forecast day. Keys are UNIX timestamps (UTC seconds since epoch) for the start of each forecast day.
Values are integer percentages (0100). Values are integer percentages (0100).
additionalProperties: additionalProperties:
@@ -1308,7 +1363,7 @@ components:
blackout_forecast_r1r2: blackout_forecast_r1r2:
type: object type: object
description: > description: >
NOAA Radio Blackout forecast probability (%) of R1R2 (MinorModerate) blackout events NOAA Radio Blackout forecast containing probability (%) of R1R2 (MinorModerate) blackout events
per day. Keys are UNIX timestamps (UTC seconds since epoch) for the start of each per day. Keys are UNIX timestamps (UTC seconds since epoch) for the start of each
forecast day. Values are integer percentages (0100). forecast day. Values are integer percentages (0100).
additionalProperties: additionalProperties:
@@ -1322,7 +1377,7 @@ components:
blackout_forecast_r3_or_greater: blackout_forecast_r3_or_greater:
type: object type: object
description: > description: >
NOAA Radio Blackout forecast probability (%) of R3 or greater (StrongExtreme) blackout NOAA Radio Blackout forecast containing probability (%) of R3 or greater (StrongExtreme) blackout
events per day. Keys are UNIX timestamps (UTC seconds since epoch) for the start of each events per day. Keys are UNIX timestamps (UTC seconds since epoch) for the start of each
forecast day. Values are integer percentages (0100). forecast day. Values are integer percentages (0100).
additionalProperties: additionalProperties:
@@ -1493,14 +1548,6 @@ components:
items: items:
$ref: '#/components/schemas/Alert' $ref: '#/components/schemas/Alert'
OkResponse:
type: string
example: "OK"
ErrorResponse:
type: string
example: "Failed"
DxStats: DxStats:
type: object type: object
description: Spot counts keyed by DE continent description: Spot counts keyed by DE continent
@@ -1637,6 +1684,20 @@ components:
type: boolean type: boolean
description: Whether the POST /spot call, to add spots to the server directly via its API, is permitted on this server. description: Whether the POST /spot call, to add spots to the server directly via its API, is permitted on this server.
example: true example: true
spot_submit_providers:
type: object
description: >
A map of SIG name to a list of provider names that support upstream spot submission for that SIG.
If a SIG appears as a key here, the POST /spot endpoint accepts `submit_upstream: true` for
spots with that SIG, and will forward the spot to one of the listed providers. Omitted if no
providers support upstream submission.
additionalProperties:
type: array
items:
type: string
example:
POTA: [POTA]
SOTA: [SOTA]
CallLookup: CallLookup:
type: object type: object
+2 -2
View File
@@ -184,7 +184,7 @@ tr.new td {
background-color: var(--bs-success-border-subtle); background-color: var(--bs-success-border-subtle);
} }
100% { 100% {
background-color: intial; background-color: initial;
} }
} }
@@ -355,7 +355,7 @@ div.band-spot:hover span.band-spot-info {
/* GENERAL MOBILE SUPPORT */ /* GENERAL MOBILE SUPPORT */
@media (max-width: 991.99px) { @media (max-width: 991px) {
/* General "hide this on mobile" class */ /* General "hide this on mobile" class */
.hideonmobile { .hideonmobile {
display: none !important; display: none !important;
+221 -13
View File
@@ -1,3 +1,27 @@
// Credentials schema per provider name. Defines the fields to collect and how to label them.
var PROVIDER_CREDENTIAL_SCHEMAS = {
// todo Figure out SOTA authentication
// see e.g. https://github.com/ham2k/app-polo/blob/main/src/extensions/activities/sota/SOTAAccount.jsx
// https://github.com/ham2k/app-polo/blob/main/src/store/apis/apiSOTA/apiSOTA.js
// Refresh token? Way to show user that they need to log in again because cached credentials aren't valid?
// todo type: text/password distinction on text boxes so API keys can be obscured
"SOTA": [
{ key: "access_token", label: "SOTA Access Token", help: "" },
{ key: "id_token", label: "SOTA ID Token", help: "TODO SOTA authentication to provide this..." }
],
"ParksNPeaks": [
{ key: "user_id", label: "Parks N Peaks User ID", help: "" },
{ key: "api_key", label: "Parks N Peaks API Key", help: "Get your API key from your Parks N Peaks account." }
],
"ZLOTA": [
{ key: "user_id", label: "ZLOTA User ID", help: "" },
{ key: "api_key", label: "ZLOTA User PIN", help: "Get your PIN from your ZLOTA account." }
],
"Tiles": [
{ key: "offline_spot_gateway_pin", label: "Offline Spot Gateway PIN", help: "Get your PIN from your Tiles on the Air account profile." }
]
};
// Load server options. Once a successful callback is made from this, we can populate the choice boxes in the form and load // Load server options. Once a successful callback is made from this, we can populate the choice boxes in the form and load
// any saved values from local storage. // any saved values from local storage.
function loadOptions() { function loadOptions() {
@@ -21,11 +45,144 @@ function loadOptions() {
})); }));
}); });
// Load reCAPTCHA if a site key is configured (key is inlined into page by server)
if (window._recaptchaSiteKey) {
loadRecaptcha(window._recaptchaSiteKey);
}
// Load settings from settings storage now all the controls are available // Load settings from settings storage now all the controls are available
loadSettings(); loadSettings();
// Update the upstream area for any pre-selected SIG
updateUpstreamArea();
}); });
} }
// Load and inject the reCAPTCHA script
function loadRecaptcha(siteKey) {
window._recaptchaSiteKey = siteKey;
if (!document.getElementById('recaptcha-script')) {
var script = document.createElement('script');
script.id = 'recaptcha-script';
script.src = 'https://www.google.com/recaptcha/api.js?render=explicit&onload=renderRecaptcha';
script.async = true;
script.defer = true;
document.head.appendChild(script);
}
$("#recaptcha-area").show();
}
// Called by reCAPTCHA after its script loads
function renderRecaptcha() {
window._recaptchaWidgetId = grecaptcha.render('recaptcha-widget', {
sitekey: window._recaptchaSiteKey,
size: 'normal'
});
}
// Update the "Send spot to..." area based on the currently selected SIG
function updateUpstreamArea() {
if (!window._allowUpstreamSpotting || !options || !options["spot_submit_providers"]) {
$("#upstream-area").hide();
return;
}
var sig = $("#sig").val();
var providers = (sig && options["spot_submit_providers"][sig]) ? options["spot_submit_providers"][sig] : [];
if (providers.length === 0) {
$("#upstream-area").hide();
return;
}
$("#upstream-area").show();
// Update the provider selector
$("#upstream-provider-select").empty();
$.each(providers, function(i, name) {
$("#upstream-provider-select").append($('<option>', { value: name, text: name }));
});
if (providers.length > 1) {
$("#upstream-provider-label").text("upstream spot sources:");
$("#upstream-provider-select-col").show();
} else {
$("#upstream-provider-label").text(providers[0]);
$("#upstream-provider-select-col").hide();
}
// Show the credentials button if this provider has an authentication mechanism and we need input from the user
updateCredentialsButton();
}
// Update the credentials button visibility based on selected provider
function updateCredentialsButton() {
var providerName = getSelectedUpstreamProvider();
if (providerName && PROVIDER_CREDENTIAL_SCHEMAS[providerName]) {
$("#upstream-credentials-btn").show();
} else {
$("#upstream-credentials-btn").hide();
}
}
// Get the currently selected upstream provider name
function getSelectedUpstreamProvider() {
var providers = (options && options["spot_submit_providers"] && $("#sig").val())
? (options["spot_submit_providers"][$("#sig").val()] || [])
: [];
if (providers.length === 0) return null;
if (providers.length === 1) return providers[0];
return $("#upstream-provider-select").val();
}
// Show the credentials modal for the currently selected upstream provider
function showCredentialsModal() {
var providerName = getSelectedUpstreamProvider();
if (!providerName || !PROVIDER_CREDENTIAL_SCHEMAS[providerName]) return;
var schema = PROVIDER_CREDENTIAL_SCHEMAS[providerName];
var stored = loadCredentials(providerName);
$("#credentials-provider-name").text(providerName);
$("#credentials-fields").empty();
$.each(schema, function(i, field) {
var val = stored[field.key] || "";
var html = '<div class="mb-3">';
html += '<label for="cred-' + field.key + '" class="form-label">' + field.label + '</label>';
html += '<input type="text" class="form-control" id="cred-' + field.key + '" value="' + $('<div>').text(val).html() + '">';
if (field.help) {
html += '<div class="form-text">' + field.help + '</div>';
}
html += '</div>';
$("#credentials-fields").append(html);
});
// Store provider name for saveCredentials()
$("#credentials-modal").data("provider", providerName);
new bootstrap.Modal(document.getElementById('credentials-modal')).show();
}
// Save credentials from the modal to local storage
function saveCredentials() {
var providerName = $("#credentials-modal").data("provider");
if (!providerName || !PROVIDER_CREDENTIAL_SCHEMAS[providerName]) return;
var schema = PROVIDER_CREDENTIAL_SCHEMAS[providerName];
var creds = {};
$.each(schema, function(i, field) {
creds[field.key] = $("#cred-" + field.key).val();
});
localStorage.setItem("upstream-credentials-" + providerName, JSON.stringify(creds));
bootstrap.Modal.getInstance(document.getElementById('credentials-modal')).hide();
}
// Load credentials for a provider from local storage
function loadCredentials(providerName) {
var stored = localStorage.getItem("upstream-credentials-" + providerName);
return stored ? JSON.parse(stored) : {};
}
// Method called to add a spot to the server // Method called to add a spot to the server
function addSpot() { function addSpot() {
try { try {
@@ -46,6 +203,7 @@ function addSpot() {
if (dx != "") { if (dx != "") {
spot["dx_call"] = dx; spot["dx_call"] = dx;
} else { } else {
// todo maybe for neatness just make all these error/rejections server side rather than having logic in two places
showAddSpotError("A DX callsign is required in order to spot."); showAddSpotError("A DX callsign is required in order to spot.");
return; return;
} }
@@ -78,21 +236,73 @@ function addSpot() {
} }
spot["time"] = moment.utc().valueOf() / 1000.0; spot["time"] = moment.utc().valueOf() / 1000.0;
// Upstream submission
var submitUpstream = $("#submit-upstream").is(":checked");
var upstreamProviderName = getSelectedUpstreamProvider();
if (submitUpstream && upstreamProviderName) {
if (!sig) {
showAddSpotError("A SIG must be selected to submit upstream.");
return;
}
if (!sigRef && upstreamProviderName !== "Tiles") {
showAddSpotError("A SIG reference is required to submit upstream.");
return;
}
if (!dxGrid && upstreamProviderName === "Tiles") {
showAddSpotError("A grid reference is required to submit upstream to Tiles on the Air.");
return;
}
if (!mode && upstreamProviderName === "Tiles") {
showAddSpotError("A mode is required to submit upstream to Tiles on the Air.");
return;
}
var creds = loadCredentials(upstreamProviderName);
spot["submit_upstream"] = true;
spot["upstream_provider"] = upstreamProviderName;
spot["upstream_credentials"] = creds;
// Add CAPTCHA token if reCAPTCHA is loaded
if (window._recaptchaWidgetId !== undefined) {
var token = grecaptcha.getResponse(window._recaptchaWidgetId);
if (!token) {
showAddSpotError("Please complete the CAPTCHA to submit upstream.");
return;
}
spot["captcha_token"] = token;
}
}
$.ajax("/api/v1/spot", { $.ajax("/api/v1/spot", {
data : JSON.stringify(spot), data : JSON.stringify(spot),
contentType : 'application/json', contentType : 'application/json',
type : 'POST', type : 'POST',
timeout: 10000, timeout: 10000,
success: async function (result) { success: async function (result) {
// Reset CAPTCHA for next use
if (window._recaptchaWidgetId !== undefined) {
grecaptcha.reset(window._recaptchaWidgetId);
}
if (result && result.startsWith && result.startsWith("Warning")) {
$("#result-good").html("<div class='alert alert-warning fade show mb-0 mt-4' role='alert'><i class='fa-solid fa-triangle-exclamation'></i> " + result + " Returning you to the spots list...</div>");
} else {
$("#result-good").html("<div class='alert alert-success fade show mb-0 mt-4' role='alert'><i class='fa-solid fa-check'></i> Spot submitted. Returning you to the spots list...</div>"); $("#result-good").html("<div class='alert alert-success fade show mb-0 mt-4' role='alert'><i class='fa-solid fa-check'></i> Spot submitted. Returning you to the spots list...</div>");
}
$("#result-bad").html(""); $("#result-bad").html("");
setTimeout(() => { setTimeout(() => {
$("#result-good").hide(); $("#result-good").hide();
window.location.replace("/"); window.location.replace("/");
}, 1000); }, 2000);
}, },
error: function (result) { error: function (result) {
showAddSpotError(result.responseText.slice(1,-1)); if (window._recaptchaWidgetId !== undefined) {
grecaptcha.reset(window._recaptchaWidgetId);
}
if (result.responseText) {
showAddSpotError(result.responseText.slice(1, -1));
} else {
showAddSpotError("The server did not return a response.");
}
} }
}); });
} catch (error) { } catch (error) {
@@ -121,20 +331,18 @@ $("#mode").change(function () {
$(this).val($(this).val().trim().toUpperCase()); $(this).val($(this).val().trim().toUpperCase());
}); });
// Display the intro box, unless the user has already dismissed it once. // Update upstream area and credentials button when SIG changes
function displayIntroBox() { $("#sig").change(function () {
if (localStorage.getItem("add-spot-intro-box-dismissed") == null) { updateUpstreamArea();
$("#add-spot-intro-box").show(); });
}
$("#add-spot-intro-box-dismiss").click(function() { // Update credentials button when provider selector changes
localStorage.setItem("add-spot-intro-box-dismissed", true); $("#upstream-provider-select").change(function () {
}); updateCredentialsButton();
} });
// Startup // Startup
$(document).ready(function() { $(document).ready(function() {
// Load options // Load options
loadOptions(); loadOptions();
// Display intro box
displayIntroBox();
}); });
+104
View File
@@ -0,0 +1,104 @@
//
// GEOGRAPHIC UTILITY FUNCTIONS
// Great Circle calculation, Maidenhead grid calcs, etc.
//
// Calculate great circle bearing between two lat/lon points.
function calcBearing(lat1, lon1, lat2, lon2) {
lat1 *= Math.PI / 180;
lon1 *= Math.PI / 180;
lat2 *= Math.PI / 180;
lon2 *= Math.PI / 180;
var lonDelta = lon2 - lon1;
var y = Math.sin(lonDelta) * Math.cos(lat2);
var x = Math.cos(lat1) * Math.sin(lat2) - Math.sin(lat1) * Math.cos(lat2) * Math.cos(lonDelta);
var bearing = Math.atan2(y, x);
bearing = bearing * (180 / Math.PI);
if ( bearing < 0 ) { bearing += 360; }
return bearing;
}
// Convert a Maidenhead grid reference of arbitrary precision to the lat/long of the centre point of the square.
// Returns null if the grid format is invalid.
function latLonForGridCentre(grid) {
let [lat, lon, latCellSize, lonCellSize] = latLonForGridSWCornerPlusSize(grid);
if (lat != null && lon != null && latCellSize != null && lonCellSize != null) {
return [lat + latCellSize / 2.0, lon + lonCellSize / 2.0];
} else {
return null;
}
}
// Convert a Maidenhead grid reference of arbitrary precision to lat/long, including in the result the size of the
// lowest grid square. This is a utility method used by the main methods that return the centre, southwest, and
// northeast coordinates of a grid square.
// The return type is always an array of size 4. The elements in it are null if the grid format is invalid.
function latLonForGridSWCornerPlusSize(grid) {
// Make sure we are in upper case so our maths works. Case is arbitrary for Maidenhead references
grid = grid.toUpperCase();
// Return null if our Maidenhead string is invalid or too short
let len = grid.length;
if (len <= 0 || (len % 2) !== 0) {
return [null, null, null, null];
}
let lat = 0.0; // aggregated latitude
let lon = 0.0; // aggregated longitude
let latCellSize = 10; // Size in degrees latitude of the current cell. Starts at 20 and gets smaller as the calculation progresses
let lonCellSize = 20; // Size in degrees longitude of the current cell. Starts at 20 and gets smaller as the calculation progresses
let latCellNo; // grid latitude cell number this time
let lonCellNo; // grid longitude cell number this time
// Iterate through blocks (two-character sections)
for (let block = 0; block * 2 < len; block += 1) {
if (block % 2 === 0) {
// Letters in this block
lonCellNo = grid.charCodeAt(block * 2) - 'A'.charCodeAt(0);
latCellNo = grid.charCodeAt(block * 2 + 1) - 'A'.charCodeAt(0);
// Bail if the values aren't in range. Allowed values are A-R (0-17) for the first letter block, or
// A-X (0-23) thereafter.
let maxCellNo = (block === 0) ? 17 : 23;
if (latCellNo < 0 || latCellNo > maxCellNo || lonCellNo < 0 || lonCellNo > maxCellNo) {
return [null, null, null, null];
}
} else {
// Numbers in this block
lonCellNo = parseInt(grid.charAt(block * 2));
latCellNo = parseInt(grid.charAt(block * 2 + 1));
// Bail if the values aren't in range 0-9..
if (latCellNo < 0 || latCellNo > 9 || lonCellNo < 0 || lonCellNo > 9) {
return [null, null, null, null];
}
}
// Aggregate the angles
lat += latCellNo * latCellSize;
lon += lonCellNo * lonCellSize;
// Reduce the cell size for the next block, unless we are on the last cell.
if (block * 2 < len - 2) {
// Still have more work to do, so reduce the cell size
if (block % 2 === 0) {
// Just dealt with letters, next block will be numbers so cells will be 1/10 the current size
latCellSize = latCellSize / 10.0;
lonCellSize = lonCellSize / 10.0;
} else {
// Just dealt with numbers, next block will be letters so cells will be 1/24 the current size
latCellSize = latCellSize / 24.0;
lonCellSize = lonCellSize / 24.0;
}
}
}
// Offset back to (-180, -90) where the grid starts
lon -= 180.0;
lat -= 90.0;
// Return nulls on maths errors
if (isNaN(lat) || isNaN(lon) || isNaN(latCellSize) || isNaN(lonCellSize)) {
return [null, null, null, null];
}
return [lat, lon, latCellSize, lonCellSize];
}
+438
View File
@@ -0,0 +1,438 @@
//
// USER INTERFACE FUNCTIONS (AMATEUR RADIO)
// Functions providing colour schemes for ham radio bands, SIG icons etc.
//
const BAND_COLOR_SCHEMES = {
"PSK Reporter": {
"2200m": "#ff4500",
"600m": "#1e90ff",
"160m": "#7cfc00",
"80m": "#e550e5",
"60m": "#00008b",
"40m": "#5959ff",
"30m": "#62d962",
"20m": "#f2c40c",
"17m": "#f2f261",
"15m": "#cca166",
"12m": "#b22222",
"11m": "#00ff00",
"10m": "#ff69b4",
"6m": "#FF0000",
"5m": "#e0e0e0",
"4m": "#cc0044",
"2m": "#FF1493",
"1.25m": "#CCFF00",
"70cm": "#999900",
"23cm": "#5AB8C7",
"13cm": "#FF7F50",
"5.8GHz": "#cc0099",
"10GHz": "#696969",
"24GHz": "#f3edc6",
"47GHz": "#ffe786",
"76GHz": "#baf9d8"
},
"PSK Reporter (Adjusted)": {
"2200m": "#ff4500",
"600m": "#1e90ff",
"160m": "#7cfc00",
"80m": "#b33fb3",
"60m": "#00008b",
"40m": "#5959ff",
"30m": "#62d962",
"20m": "#f2c40c",
"17m": "#f2f261",
"15m": "#cca166",
"12m": "#b22222",
"11m": "#00ff00",
"10m": "#ff7eb4",
"6m": "#FF0000",
"5m": "#e0e0e0",
"4m": "#cc0044",
"2m": "#FF1493",
"1.25m": "#CCFF00",
"70cm": "#999900",
"23cm": "#5AB8C7",
"13cm": "#FF7F50",
"5.8GHz": "#cc0099",
"10GHz": "#696969",
"24GHz": "#f3edc6",
"47GHz": "#ffe786",
"76GHz": "#baf9d8"
},
"RBN": {
"2200m": "#000000",
"600m": "#aaaaaa",
"160m": "#ffe000",
"80m": "#093F00",
"60m": "#777777",
"40m": "#ffa500",
"30m": "#ff0000",
"20m": "#800080",
"17m": "#0000ff",
"15m": "#444444",
"12m": "#00ffff",
"11m": "#000000",
"10m": "#ff00ff",
"6m": "#ffc0cb",
"5m": "#000000",
"4m": "#a276ff",
"2m": "#92FF7F",
"1.25m": "#000000",
"70cm": "#000000",
"23cm": "#000000",
"13cm": "#000000",
"5.8GHz": "#000000",
"10GHz": "#000000",
"24GHz": "#000000",
"47GHz": "#000000",
"76GHz": "#000000"
},
"Ham Rainbow": {
"2200m": "#8e4f37",
"600m": "#8e4f37",
"160m": "#8e3737",
"80m": "#da2f93",
"60m": "#792fda",
"40m": "#2f4bda",
"30m": "#2fdad2",
"20m": "#68da2f",
"17m": "#dad52f",
"15m": "#da832f",
"12m": "#da5c2f",
"11m": "#8e8e8e",
"10m": "#da2f2f",
"6m": "#8e377a",
"5m": "#8e8e8e",
"4m": "#42378e",
"2m": "#37748e",
"1.25m": "#8e8e8e",
"70cm": "#378e65",
"23cm": "#8e8e37",
"13cm": "#8e6037",
"5.8GHz": "#8e6037",
"10GHz": "#8e6037",
"24GHz": "#8e6037",
"47GHz": "#8e6037",
"76GHz": "#8e6037"
},
"Ham Rainbow (Reverse)": {
"2200m": "#42378e",
"600m": "#42378e",
"160m": "#8e377a",
"80m": "#da2f2f",
"60m": "#da5c2f",
"40m": "#da832f",
"30m": "#dad52f",
"20m": "#68da2f",
"17m": "#2fdad2",
"15m": "#2f4bda",
"12m": "#792fda",
"11m": "#8e8e8e",
"10m": "#da2f93",
"6m": "#8e3737",
"5m": "#8e8e8e",
"4m": "#8e4f37",
"2m": "#8e6037",
"1.25m": "#8e8e8e",
"70cm": "#8e8e37",
"23cm": "#378e65",
"13cm": "#37748e",
"5.8GHz": "#37748e",
"10GHz": "#37748e",
"24GHz": "#37748e",
"47GHz": "#37748e",
"76GHz": "#37748e",
},
"Kate Morley": {
"2200m": "#817",
"600m": "#817",
"160m": "#817",
"80m": "#a35",
"60m": "#c66",
"40m": "#e94",
"30m": "#ed0",
"20m": "#9d5",
"17m": "#4d8",
"15m": "#2cb",
"12m": "#0bc",
"11m": "#09c",
"10m": "#09c",
"6m": "#36b",
"5m": "#36b",
"4m": "#36b",
"2m": "#36b",
"1.25m": "#36b",
"70cm": "#639",
"23cm": "#639",
"13cm": "#639",
"5.8GHz": "#639",
"10GHz": "#639",
"24GHz": "#639",
"47GHz": "#639",
"76GHz": "#639",
},
"ColorBrewer": {
"2200m": "#54278f",
"600m": "#756bb1",
"160m": "#9e9ac8",
"80m": "#cbc9e2",
"60m": "#08519c",
"40m": "#3182bd",
"30m": "#6baed6",
"20m": "#bdd7e7",
"17m": "#006d2c",
"15m": "#31a354",
"12m": "#74c476",
"11m": "#bae4b3",
"10m": "#a63603",
"6m": "#e6550d",
"5m": "#fd8d3c",
"4m": "#fdbe85",
"2m": "#a50f15",
"1.25m": "#de2d26",
"70cm": "#fb6a4a",
"23cm": "#fcae91",
"13cm": "#636363",
"5.8GHz": "#636363",
"10GHz": "#969696",
"24GHz": "#969696",
"47GHz": "#cccccc",
"76GHz": "#cccccc",
},
"IWantHue": {
"2200m": "#409271",
"600m": "#b03ce1",
"160m": "#50c640",
"80m": "#d545b7",
"60m": "#99b936",
"40m": "#7260db",
"30m": "#60af57",
"20m": "#d54788",
"17m": "#58c79f",
"15m": "#e2462a",
"12m": "#49b1d3",
"11m": "#df872f",
"10m": "#506bb0",
"6m": "#c6a639",
"5m": "#9554a3",
"4m": "#36783c",
"2m": "#da405b",
"1.25m": "#657527",
"70cm": "#8c97e2",
"23cm": "#b44f2f",
"13cm": "#d386c8",
"5.8GHz": "#aaac66",
"10GHz": "#9d4760",
"24GHz": "#90672c",
"47GHz": "#e08086",
"76GHz": "#dc9769",
},
"IWantHue (Color Blind)": {
"2200m": "#bf9e3d",
"600m": "#9d2fec",
"160m": "#79df39",
"80m": "#d445db",
"60m": "#5dd175",
"40m": "#814dd8",
"30m": "#d7ce2f",
"20m": "#657af1",
"17m": "#8cc34a",
"15m": "#d635aa",
"12m": "#6cbd80",
"11m": "#b860c1",
"10m": "#e48721",
"6m": "#686ccc",
"5m": "#d44e2b",
"4m": "#51b3db",
"2m": "#d74058",
"1.25m": "#56c5ad",
"70cm": "#d0478d",
"23cm": "#708940",
"13cm": "#c380c2",
"5.8GHz": "#cab775",
"10GHz": "#7a7fc2",
"24GHz": "#b87148",
"47GHz": "#bd678c",
"76GHz": "#c3666b",
},
"Mokole": {
"2200m": "#8b4513",
"600m": "#006400",
"160m": "#808000",
"80m": "#483d8b",
"60m": "#5f9ea0",
"40m": "#000080",
"30m": "#9acd32",
"20m": "#8b008b",
"17m": "#ff0000",
"15m": "#ff8c00",
"12m": "#ffd700",
"11m": "#7fff00",
"10m": "#8a2be2",
"6m": "#00ff7f",
"5m": "#dc143c",
"4m": "#00bfff",
"2m": "#0000ff",
"1.25m": "#d8bfd8",
"70cm": "#ff00ff",
"23cm": "#1e90ff",
"13cm": "#db7093",
"5.8GHz": "#f0e68c",
"10GHz": "#ff1493",
"24GHz": "#ffa07a",
"47GHz": "#ee82ee",
"76GHz": "#7fffd4",
}
};
let bandColorScheme = "PSK Reporter (Adjusted)";
// Set the band colour scheme. Returns true if successful, false if the requested scheme was not known
function setBandColorScheme(scheme) {
let ret = BAND_COLOR_SCHEMES[scheme]
if (ret) {
bandColorScheme = scheme;
}
return ret;
}
// Get the list of known bands
function getKnownBands() {
return Array.from(Object.keys(BAND_COLOR_SCHEMES[bandColorScheme]));
}
// Get the list of available band colour schemes
function getAvailableBandColorSchemes() {
return Array.from(Object.keys(BAND_COLOR_SCHEMES));
}
// Band name to colour (in the current colour scheme). If the band is unknown, black will be returned.
function bandToColor(band) {
let col = (band != null) ? BAND_COLOR_SCHEMES[bandColorScheme][band] : null;
if (col) {
return col;
} else {
return "#000000";
}
}
// Band name to contrast colour (in the current colour scheme). This is either black or white, contrasting as well as
// possible with the band colour. If the band is unknown, white will be returned.
function bandToContrastColor(band) {
const rgb = hexToRGB(bandToColor(band));
const lum = 0.2126*rgb[0] + 0.7152*rgb[1] + 0.0722*rgb[2];
return (lum > 128) ? "#000000" : "#ffffff";
}
const MODE_TYPE_COLOR_SCHEMES = {
"CW": "red",
"PHONE": "green",
"DATA": "blue"
}
// Mode type (CW, PHONE, DATA) to colour. If the mode type is unknown, black will be returned.
function modeTypeToColor(modeType) {
let col = (modeType != null) ? MODE_TYPE_COLOR_SCHEMES[modeType.toUpperCase()] : null;
if (col) {
return col;
} else {
return "#000000";
}
}
const SIG_ICONS = {
"POTA": "fa-tree",
"SOTA": "fa-mountain-sun",
"WWFF": "fa-seedling",
"GMA": "fa-person-hiking",
"WWBOTA": "fa-radiation",
"HEMA": "fa-mound",
"IOTA": "fa-book-atlas",
"MOTA": "fa-fan",
"ARLHS": "fa-house-flood-water",
"ILLW": "fa-house-flood-water",
"SIOTA": "fa-wheat-awn",
"WCA": "fa-chess-rook",
"ZLOTA": "fa-kiwi-bird",
"WOTA": "fa-w",
"BOTA": "fa-umbrella-beach",
"KRMNPA": "fa-earth-oceania",
"LLOTA": "fa-water",
"WWTOTA": "fa-tower-observation",
"WAB": "fa-table-cells-large",
"WAI": "fa-table-cells-large",
"Tiles": "fa-square",
"TOTA": "fa-toilet"
}
const SIG_NAMES = {
"POTA": "Parks on the Air",
"SOTA": "Summits on the Air",
"WWFF": "Worldwide Flora & Fauna",
"GMA": "Global Mountain Activity",
"WWBOTA": "Bunkers on the Air",
"HEMA": "Humps Excluding Marilyns Award",
"IOTA": "Islands on the Air",
"MOTA": "Mills on the Air",
"ARLHS": "Amateur Radio Lighthouse Society",
"ILLW": "International Lighthouse Lightship Weekend",
"SIOTA": "Silos on the Air",
"WCA": "World Castles Award",
"ZLOTA": "New Zealand on the Air",
"WOTA": "Wainwrights on the Air",
"BOTA": "Beaches on the Air",
"KRMNPA": "Keith Roget Memorial National Parks Award",
"LLOTA": "Lagos y Lagunas on the Air",
"WWTOTA": "Towers on the Air",
"WAB": "Worked All Britain",
"WAI": "Worked All Ireland",
"Tiles": "Tiles on the Air",
"TOTA": "Toilets on the Air"
}
// Get the Font Awesome icon for a given SIG. If the SIG is unknown, the provided default symbol will be returned
function sigToIcon(sig, defaultIcon) {
let col = (sig != null) ? SIG_ICONS[sig] : null;
if (col) {
return col;
} else {
let col = (sig != null) ? SIG_ICONS[sig.toUpperCase()] : null;
if (col) {
return col;
} else {
return defaultIcon;
}
}
}
// Get the full name for a given SIG abbreviation. If the SIG is unknown, an empty string will be returned.
function sigToName(sig) {
let col = (sig != null) ? SIG_NAMES[sig] : null;
if (col) {
return col;
} else {
let col = (sig != null) ? SIG_NAMES[sig.toUpperCase()] : null;
if (col) {
return col;
} else {
return "";
}
}
}
// Get the list of known SIGs
function getKnownSIGs() {
return Array.from(Object.keys(SIG_ICONS));
}
// Format a Maidenhead grid with alternating alphabetic blocks in lower case
function formatGrid(grid) {
grid = grid.toUpperCase();
if (grid.length >= 6) {
grid = grid.substring(0, 4) + grid.substring(4, 6).toLowerCase() + grid.substring(6);
}
if (grid.length >= 12) {
grid = grid.substring(0, 10) + grid.substring(10, 12).toLowerCase() + grid.substring(14);
}
return grid;
}
+33
View File
@@ -0,0 +1,33 @@
//
// GENERAL UTILITY FUNCTIONS
// OBject, string manipulation etc.
//
// Utility function to escape HTML characters from a string.
function escapeHtml(str) {
if (typeof str !== 'string') {
return '';
}
const escapeCharacter = (match) => {
switch (match) {
case '&': return '&amp;';
case '<': return '&lt;';
case '>': return '&gt;';
case '"': return '&quot;';
case '\'': return '&#039;';
case '`': return '&#096;';
default: return match;
}
};
return str.replace(/[&<>"'`]/g, escapeCharacter);
}
// Converts an HTML hex colour to an array of [R, G, B] where each is 0-255.
function hexToRGB(hex) {
return hex.replace(/^#?([a-f\d])([a-f\d])([a-f\d])$/i
,(m, r, g, b) => '#' + r + r + g + g + b + b)
.substring(1).match(/.{2}/g)
.map(x => parseInt(x, 16));
}
File diff suppressed because one or more lines are too long
Binary file not shown.

After

Width:  |  Height:  |  Size: 1.2 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 696 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.4 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.4 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 618 B

File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
Binary file not shown.

After

Width:  |  Height:  |  Size: 117 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 248 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 535 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.4 KiB

File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
+113
View File
@@ -0,0 +1,113 @@
/*
* L.Maidenhead displays a Maidenhead Locator of lines on the map.
*/
L.Maidenhead = L.LayerGroup.extend({
options: {
// Line and label color
color: 'rgba(255, 0, 0, 0.4)',
// Redraw on move or moveend
redraw: 'move'
},
initialize: function (options) {
L.LayerGroup.prototype.initialize.call(this);
L.Util.setOptions(this, options);
},
onAdd: function (map) {
this._map = map;
var grid = this.redraw();
this._map.on('viewreset '+ this.options.redraw, function () {
grid.redraw();
});
this.eachLayer(map.addLayer, map);
},
onRemove: function (map) {
// remove layer listeners and elements
map.off('viewreset '+ this.options.redraw, this.map);
this.eachLayer(this.removeLayer, this);
},
redraw: function () {
var d3 = new Array(20,10,10,10,10,10,1 ,1 ,1 ,1 ,1/24,1/24,1/24,1/24,1/24,1/240,1/240,1/240,1/240/24,1/240/24,1/240/24 );
var lat_cor = new Array(0 ,8 ,8 ,8 ,10,14,6 ,8 ,8 ,8 ,1.4 ,2.5 ,3 ,3.5 ,4 ,4 ,3.5 ,3.5 ,1.47 ,1.8 ,1.6 );
var bounds = map.getBounds();
var zoom = map.getZoom();
var unit = d3[Math.round(zoom)];
var lcor = lat_cor[Math.round(zoom)];
var w = bounds.getWest();
var e = bounds.getEast();
var n = bounds.getNorth();
var s = bounds.getSouth();
if (zoom==1) {var c = 2;} else {var c = 0.1;}
if (n > 85) n = 85;
if (s < -85) s = -85;
var left = Math.floor(w/(unit*2))*(unit*2);
var right = Math.ceil(e/(unit*2))*(unit*2);
var top = Math.ceil(n/unit)*unit;
var bottom = Math.floor(s/unit)*unit;
this.eachLayer(this.removeLayer, this);
for (var lon = left; lon < right; lon += (unit*2)) {
for (var lat = bottom; lat < top; lat += unit) {
var bounds = [[lat,lon],[lat+unit,lon+(unit*2)]];
this.addLayer(L.rectangle(bounds, {color: this.options.color, weight: 1, fill:false, interactive: false}));
//var pont = map.latLngToLayerPoint([lat,lon]);
//console.log(pont.x);
this.addLayer(this._getLabel(lon+unit-(unit/lcor),lat+(unit/2)+(unit/lcor*c)));
}
}
return this;
},
_getLabel: function(lon,lat) {
var title_size = new Array(0 ,10,12,16,20,26,12,16,24,36,12 ,14 ,20 ,36 ,60 ,12 ,20 ,36 ,8 ,12 ,24 );
var zoom = map.getZoom();
var size = title_size[Math.round(zoom)]+'px';
var title = '<span style="cursor: default;"><font style="color:'+this.options.color+'; font-size:'+size+'; font-weight: 900; ">' + this._getLocator(lon,lat) + '</font></span>';
var myIcon = L.divIcon({className: 'my-div-icon', html: title});
var marker = L.marker([lat,lon], {icon: myIcon}, clickable=false);
return marker;
},
_getLocator: function(lon,lat) {
var ydiv_arr=new Array(10, 1, 1/24, 1/240, 1/240/24);
var d1 = "ABCDEFGHIJKLMNOPQR".split("");
var d2 = "ABCDEFGHIJKLMNOPQRSTUVWX".split("");
var d4 = new Array(0 ,1 ,1 ,1 ,1 ,1 ,2 ,2 ,2 ,2 ,3 ,3 ,3 ,3 ,3 ,4 ,4 ,4 ,5 ,5 ,5 );
var locator = "";
var x = lon;
var y = lat;
var precision = d4[Math.round(map.getZoom())];
while (x < -180) {x += 360;}
while (x > 180) {x -=360;}
x = x + 180;
y = y + 90;
locator = locator + d1[Math.floor(x/20)] + d1[Math.floor(y/10)];
for (var i=0; i<4; i=i+1) {
if (precision > i+1) {
rlon = x%(ydiv_arr[i]*2);
rlat = y%(ydiv_arr[i]);
if ((i%2)==0) {
locator += Math.floor(rlon/(ydiv_arr[i+1]*2)) +""+ Math.floor(rlat/(ydiv_arr[i+1]));
} else {
locator += d2[Math.floor(rlon/(ydiv_arr[i+1]*2))] +""+ d2[Math.floor(rlat/(ydiv_arr[i+1]))];
}
}
}
return locator;
},
});
L.maidenhead = function (options) {
return new L.Maidenhead(options);
};
File diff suppressed because it is too large Load Diff
+8
View File
@@ -0,0 +1,8 @@
/**
* Minified by jsDelivr using Terser v5.37.0.
* Original file: /npm/@joergdietrich/leaflet.terminator@1.1.0/L.Terminator.js
*
* Do NOT use SRI with dynamically generated files! More information: https://www.jsdelivr.com/using-sri-with-dynamic-files
*/
!function(t,i){"object"==typeof exports&&"undefined"!=typeof module?module.exports=i(require("leaflet")):"function"==typeof define&&define.amd?define(["leaflet"],i):(t.L=t.L||{},t.L.terminator=i(t.L))}(this,(function(t){"use strict";var i=(t=t&&t.hasOwnProperty("default")?t.default:t).Polygon.extend({options:{color:"#00",opacity:.5,fillColor:"#00",fillOpacity:.5,resolution:2},initialize:function(i){this.version="0.1.0",this._R2D=180/Math.PI,this._D2R=Math.PI/180,t.Util.setOptions(this,i);var n=this._compute(this.options.time);this.setLatLngs(n)},setTime:function(t){this.options.time=t;var i=this._compute(t);this.setLatLngs(i)},_sunEclipticPosition:function(t){var i=t-2451545,n=280.46+.9856474*i,e=357.528+.9856003*i;return e%=360,{lambda:(n%=360)+1.915*Math.sin(e*this._D2R)+.02*Math.sin(2*e*this._D2R),R:1.00014-.01671*Math.cos(e*this._D2R)-.0014*Math.cos(2*e*this._D2R)}},_eclipticObliquity:function(t){var i=(t-2451545)/36525;return 23.43929111-i*(46.836769/3600-i*(1831e-7/3600+i*(5.565e-7-i*(1.6e-10-4.34e-8*i/3600))))},_sunEquatorialPosition:function(t,i){var n=Math.atan(Math.cos(i*this._D2R)*Math.tan(t*this._D2R))*this._R2D,e=Math.asin(Math.sin(i*this._D2R)*Math.sin(t*this._D2R))*this._R2D;return{alpha:n+=90*Math.floor(t/90)-90*Math.floor(n/90),delta:e}},_hourAngle:function(t,i,n){return 15*(n+t/15)-i.alpha},_latitude:function(t,i){return Math.atan(-Math.cos(t*this._D2R)/Math.tan(i.delta*this._D2R))*this._R2D},_compute:function(t){for(var i=t?new Date(t):new Date,n=i/864e5+2440587.5,e=function(t){return(18.697374558+24.06570982441908*(t-2451545))%24}(n),o=[],s=this._sunEclipticPosition(n),a=this._eclipticObliquity(n),h=this._sunEquatorialPosition(s.lambda,a),r=0;r<=720*this.options.resolution;r++){var u=r/this.options.resolution-360,l=this._hourAngle(u,h,e);o[r+1]=[this._latitude(l,h),u]}return h.delta<0?(o[0]=[90,-360],o[o.length]=[90,360]):(o[0]=[-90,-360],o[o.length]=[-90,360]),o}});return function(t){return new i(t)}}));
//# sourceMappingURL=/sm/0ad6cc527a0e7748dc9ea3acda4c2088f86401baae9f9dddc41fc73f9afa2de2.map
File diff suppressed because one or more lines are too long
+259
View File
@@ -0,0 +1,259 @@
L.WorkedAllBritainIreland = L.LayerGroup.extend({
options: {
// Line and label color
color: 'rgba(80, 80, 80, 1)',
// Grid squares to draw
gbSquares: ["HP", "HT", "HU", "HW", "HX", "HY", "HZ", "NA", "NB", "NC", "ND", "NF", "NG", "NH", "NJ", "NK", "NL", "NM", "NN", "NO", "NR", "NS", "NT", "NU", "NW", "NX", "NY", "NZ", "OV", "SC", "SD", "SE", "SH", "SJ", "SK", "SM", "SN", "SO", "SP", "SR", "SS", "ST", "SU", "SV", "SW", "SX", "SY", "SZ", "TA", "TF", "TG", "TL", "TM", "TR", "TQ", "TV"],
ieSquares: ["B", "C", "D", "F", "G", "H", "J", "L", "M", "N", "O", "Q", "R", "S", "T", "V", "W", "X"],
ciSquares: ["WA", "WV"]
},
initialize: function (options) {
// Initialise the LayerGroup superclass and set the options for this class.
L.LayerGroup.prototype.initialize.call(this);
L.Util.setOptions(this, options);
// Workaround to load the geodesy modules in non-modular code. Once we have loaded all three modules, trigger a
// first draw.
import("https://misc.ianrenton.com/Leaflet.WorkedAllBritainIreland/modules/geodesy/osgridref.js")
.then(module => {
this._osGridLibrary = module;
if (this._ieGridLibrary && this._utmLibrary) {
this.redraw();
}
})
.catch(error => {
console.log("Error loading OS Grid Ref library, GB WAB squares may not be available.");
console.log(error);
});
import("https://misc.ianrenton.com/Leaflet.WorkedAllBritainIreland/modules/geodesy/iegridref.js")
.then(module => {
this._ieGridLibrary = module;
if (this._osGridLibrary && this._utmLibrary) {
this.redraw();
}
})
.catch(error => {
console.log("Error loading IE Grid Ref library, NI WAB squares may not be available.");
console.log(error);
});
import("https://misc.ianrenton.com/Leaflet.WorkedAllBritainIreland/modules/geodesy/utm_ci.js")
.then(module => {
this._utmLibrary = module;
if (this._osGridLibrary && this._ieGridLibrary) {
this.redraw();
}
})
.catch(error => {
console.log("Error loading UTM library, Channel Islands WAB squares may not be available.");
console.log(error);
});
},
onAdd: function (map) {
this._map = map;
var grid = this.redraw();
map.on('moveend', function () { grid.redraw(); });
map.on('zoomend', function () { grid.redraw(); });
this.eachLayer(map.addLayer, map);
},
onRemove: function (map) {
map.off('moveend', this.map);
map.off('zoomend', this.map);
this.eachLayer(this.removeLayer, this);
},
redraw: function () {
// Don't proceed unless we have a map object and our libraries are loaded
if (this._map && this._osGridLibrary && this._ieGridLibrary && this._utmLibrary) {
// Remove existing content
this.eachLayer(this.removeLayer, this);
// Determine detail level based on current map zoom.
const detailLevel = (map.getZoom() > 4) ? (map.getZoom() > 8) ? 2 : 1 : 0;
// Generate new content for the three grid systems.
this.options.gbSquares.forEach(squareRef => {
this._addWABGraphicsForSquare(squareRef, "GB", detailLevel);
});
this.options.ieSquares.forEach(squareRef => {
this._addWABGraphicsForSquare(squareRef, "IE", detailLevel);
});
this.options.ciSquares.forEach(squareRef => {
this._addWABGraphicsForSquare(squareRef, "CI", detailLevel);
});
}
return this;
},
// Add WAB graphics to the layer for the given square, using the given grid system ("GB", "IE" or "CI") and the
// required level of detail.
_addWABGraphicsForSquare: function (squareRef, gridSystem, detailLevel) {
if (detailLevel === 0 || detailLevel === 1) {
// If detail level is 0 or 1, we want a single large square.
const swCorner = this._gridRefToLatLon(squareRef + " 00000 00000", gridSystem);
const nwCorner = this._gridRefToLatLon(squareRef + " 99999 00000", gridSystem);
const neCorner = this._gridRefToLatLon(squareRef + " 99999 99999", gridSystem);
const seCorner = this._gridRefToLatLon(squareRef + " 00000 99999", gridSystem);
const centre = this._gridRefToLatLon(squareRef + " 50000 50000", gridSystem);
let square = L.polygon([swCorner, nwCorner, neCorner, seCorner], {color: this.options.color, interactive: false});
this.addLayer(square);
// Additionally if detail level is 1, we want to label it.
if (detailLevel === 1) {
let label = new L.marker(centre, {
icon: new L.DivIcon({
html: '<span style="position: relative; top: -40%; left: -40%; text-align: center; cursor: hand; color:'+this.options.color+'; font-weight: bold; font-size:120%;">' + squareRef + '</font></span>',
className: 'wabSquareLabel' // Prevent default background & border and provide ability to customise
})
}, clickable=false);
this.addLayer(label);
}
} else if (detailLevel === 2) {
// If detail level is 2, we want to generate all the inner squares (with labels)
// instead of just one square. But, doing this for every square will cause CPU issues,
// so we only want to generate graphics if they would actually end up on screen.
for (let i = 0; i < 10; i++) {
for (let j = 0; j < 10; j++) {
// Bail out if we have a grid reference that doesn't apply. This is where GB grid overlaps with NI etc.
// are deconflicted.
if (this._validSmallSquare(squareRef, i, j)) {
// If we get this far, now calculate the coordinates of the box.
const swCorner = this._gridRefToLatLon(squareRef + " " + i + "0000 " + j + "0000", gridSystem);
const nwCorner = this._gridRefToLatLon(squareRef + " " + i + "9999 " + j + "0000", gridSystem);
const neCorner = this._gridRefToLatLon(squareRef + " " + i + "9999 " + j + "9999", gridSystem);
const seCorner = this._gridRefToLatLon(squareRef + " " + i + "0000 " + j + "9999", gridSystem);
const centre = this._gridRefToLatLon(squareRef + " " + i + "5000 " + j + "5000", gridSystem);
// Find out if this box is going to be on our screen. If not, don't draw anything.
if (map.getBounds().contains(swCorner) || map.getBounds().contains(nwCorner)
|| map.getBounds().contains(neCorner) || map.getBounds().contains(seCorner)) {
let square = L.polygon([swCorner, nwCorner, neCorner, seCorner], {color: this.options.color, interactive: false});
this.addLayer(square);
let label = new L.marker(centre, {
icon: new L.DivIcon({
html: '<span style="position: relative; top: -20%; left: -100%; text-align: center; cursor: hand; color:'+this.options.color+'; font-weight: bold; font-size:120%;">' + squareRef + i + j + '</font></span>',
className: 'wabSquareLabelLong' // Prevent default background & border and provide ability to customise
})
}, clickable=false);
this.addLayer(label);
}
}
}
}
}
},
// Determine if a given small square is OK to draw. This is where e.g. overlapping GB and IE squares are
// deconflicted in the Irish Sea.
_validSmallSquare: function (squareRef, i, j) {
let valid = true;
if (squareRef === "WA" && j > 1) {
valid = false;
} else if (squareRef === "TR" && i > 4 && j < 5) {
valid = false;
} else if (squareRef === "SM" && i < 4) {
valid = false;
} else if (squareRef === "SM" && i < 7 && j > 4) {
valid = false;
} else if (squareRef === "TV" && i === 9 && j === 0) {
valid = false;
} else if (squareRef === "NW" && i < 9) {
valid = false;
} else if (squareRef === "NR" && i < 5 && j < 3) {
valid = false;
} else if (squareRef === "C" && j > 6) {
valid = false;
} else if (squareRef === "D" && (i > 5 || j > 5 || (i > 2 && j > 3))) {
valid = false;
} else if (squareRef === "J" && i > 6) {
valid = false;
} else if (squareRef === "O" && i > 8) {
valid = false;
} else if (squareRef === "T" && i > 6 && j < 5) {
valid = false;
}
return valid;
},
// Convert the given grid reference to lat/lon, using the given grid system ("GB", "IE" or "CI")
_gridRefToLatLon: function (grid, gridSystem) {
if (gridSystem === "GB") {
return this._osgbGridRefToLatLon(grid);
} else if (gridSystem === "IE") {
return this._osieGridRefToLatLon(grid);
} else if (gridSystem === "CI") {
return this._ciGridRefToLatLon(grid);
} else {
return null;
}
},
// OSGB grid reference to lat/lon
_osgbGridRefToLatLon: function (grid) {
if (this._osGridLibrary) {
return this._osGridLibrary.default.parse(grid).toLatLon();
} else {
return null;
}
},
// Lat/lon to OSGB grid reference
_latLonToOSGBGridRef: function (lat, lon) {
if (this._osGridLibrary) {
return new this._osGridLibrary.LatLon(lat, lon).toOsGrid();
} else {
return null;
}
},
// OSIE grid reference to lat/lon
_osieGridRefToLatLon: function (grid) {
if (this._ieGridLibrary) {
return this._ieGridLibrary.default.parse(grid).toLatLon();
} else {
return null;
}
},
// Lat/lon to OSIE grid reference
_latLonToOSIEGridRef: function (lat, lon) {
if (this._ieGridLibrary) {
return new this._ieGridLibrary.LatLon(lat, lon).toOsGrid();
} else {
return null;
}
},
// CI grid reference to lat/lon
_ciGridRefToLatLon: function (grid) {
if (this._utmLibrary) {
return this._utmLibrary.default.parseChannelIslandGrid(grid).toLatLon();
} else {
return null;
}
},
// Lat/lon to CI grid reference
_latLonToCIGridRef: function (lat, lon) {
if (this._utmLibrary) {
let utm = (new this._utmLibrary.LatLon(lat, lon)).toUtm();
// todo convert UTM coordinate system back to CI grid ref cells
return null;
} else {
return null;
}
}
});
L.workedAllBritainIreland = function (options) {
return new L.WorkedAllBritainIreland(options);
};
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
+1
View File
@@ -0,0 +1 @@
!function(){var t,n=document.createElement("pre"),h=document.createElement("canvas"),r=h.getContext("2d"),i={font:"Sans-serif",align:"left",color:"#000000",size:16,background:"rgba(0, 0, 0, 0)",stroke:0,strokeColor:"#FFFFFF",lineHeight:"1.2em",bold:!1,italic:!1};function s(t){t=String(t),n.innerText=t,n.setAttribute("style",this._style),document.body.append(n);var e=t.split("\n"),i=this.style.stroke,s=n.offsetHeight/e.length,l=.25*s;h.width=n.offsetWidth+2*i,h.height=n.offsetHeight,r.clearRect(0,0,h.width,h.height),r.fillStyle=this.style.background,r.beginPath(),r.fillRect(0,0,h.width,h.height),r.fill();var o="";switch(this.style.italic&&(o+="italic "),this.style.bold&&(o+="bold "),o+=this.style.size+"pt "+this.style.font,r.font=o,r.textAlign=this.style.align,r.lineWidth=this.style.stroke,r.strokeStyle=this.style.strokeColor,r.fillStyle=this.style.color,r.textAlign){case"center":i=h.width/2;break;case"right":i=h.width-i}e.forEach(function(t,e){this.style.stroke&&r.strokeText(t,i,s*(e+1)-l),r.fillText(t,i,s*(e+1)-l)}.bind(this)),document.body.removeChild(n)}window.TextImage=function(t){return this instanceof TextImage?(this.setStyle(t),this):new TextImage(t)},(t=window.TextImage.prototype).setStyle=function(t){for(var e in this.style=t||{},i)this.style[e]||(this.style[e]=i[e]);return this._style="font: ",this.style.italic&&(this._style+="italic "),this.style.bold&&(this._style+="bold "),this._style+=this.style.size+"pt "+this.style.font+";",this._style+="line-height:"+this.style.lineHeight+";",this._style+="text-align: "+this.style.align+";",this._style+="color: "+this.style.color+";",this._style+="background-color: "+this.style.background+";",this._style+=";padding: 0; display: block; position: fixed; top: 100%; overflow: hidden;",this},t.toDataURL=function(t){return t&&s.call(this,t),h.toDataURL()},t.toImage=function(t,e){s.call(this,t);var i=new Image;return e&&(i.onload=e),i.src=h.toDataURL(),i}}();
File diff suppressed because one or more lines are too long