Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
88f055384d | ||
|
|
4408203d55 | ||
|
|
af9f542740 | ||
|
|
b81f5eeb5a | ||
|
|
fd21e01c9d | ||
|
|
1afb407ca5 | ||
|
|
930d5357fe |
@@ -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/apidocs` - Contains the OpenAPI spec (`openapi.yml`)
|
||||
* `/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/js` - JavaScript used by the web front-end
|
||||
* `/webassets/vendor` - Third-party libraries (CSS, JS, fonts and images)
|
||||
|
||||
*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 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 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.
|
||||
|
||||
|
||||
@@ -12,7 +12,7 @@ class ParksNPeaks(HTTPAlertProvider):
|
||||
"""Alert provider for Parks n Peaks"""
|
||||
|
||||
POLL_INTERVAL_SEC = 1800
|
||||
ALERTS_URL = "http://parksnpeaks.org/api/ALERTS/"
|
||||
ALERTS_URL = "https://parksnpeaks.org/api/ALERTS/"
|
||||
|
||||
def __init__(self, provider_config):
|
||||
super().__init__(provider_config, self.ALERTS_URL, self.POLL_INTERVAL_SEC)
|
||||
|
||||
@@ -212,6 +212,20 @@ clublog-api-key: ""
|
||||
# Allow submitting spots to the Spothole API?
|
||||
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.
|
||||
web-ui-options:
|
||||
spot-count: [10, 25, 50, 100]
|
||||
|
||||
@@ -14,20 +14,27 @@ with open("config.yml") as f:
|
||||
config = yaml.safe_load(f)
|
||||
logging.info("Loaded config.")
|
||||
|
||||
# TODO load other keys with config.get(key, default) instead of config[key]
|
||||
BASE_URL = config["base-url"]
|
||||
MAX_SPOT_AGE = config["max-spot-age-sec"]
|
||||
MAX_ALERT_AGE = config["max-alert-age-sec"]
|
||||
SERVER_OWNER_CALLSIGN = config["server-owner-callsign"]
|
||||
WEB_SERVER_PORT = config["web-server-port"]
|
||||
ALLOW_SPOTTING = config["allow-spotting"]
|
||||
ALLOW_UPSTREAM_SPOTTING = config.get("allow-upstream-spotting", True)
|
||||
WEB_UI_OPTIONS = config["web-ui-options"]
|
||||
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
|
||||
# but for consistency we provide this to the front-end in web-ui-options because it has no impact outside of the web UI.
|
||||
WEB_UI_OPTIONS["spot-providers-enabled-by-default"] = [p["name"] for p in config["spot-providers"] if p["enabled"] and (
|
||||
"enabled-by-default-in-web-ui" not in p or p["enabled-by-default-in-web-ui"])]
|
||||
# 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:
|
||||
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
|
||||
|
||||
@@ -11,7 +11,7 @@ HAMQTH_PRG = ("Spothole v" + SOFTWARE_VERSION + " operated by " + SERVER_OWNER_C
|
||||
|
||||
# Special Interest Groups
|
||||
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="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}"),
|
||||
@@ -37,10 +37,12 @@ SIGS = [
|
||||
|
||||
# Modes. Note "DIGI" and "DIGITAL" are also supported but are normalised into "DATA".
|
||||
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"]
|
||||
ALL_MODES = CW_MODES + PHONE_MODES + DATA_MODES
|
||||
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
|
||||
# we already know, or we want to normalise things for consistency. The lookup table for this is here. Incoming spots
|
||||
|
||||
@@ -18,7 +18,6 @@ from core.cache_utils import SEMI_STATIC_URL_DATA_CACHE
|
||||
from core.config import config
|
||||
from core.constants import BANDS, UNKNOWN_BAND, CW_MODES, PHONE_MODES, DATA_MODES, ALL_MODES, \
|
||||
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_FIELD_MAP = {
|
||||
|
||||
@@ -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.lookup_helper import lookup_helper, infer_band_from_freq, infer_mode_from_comment, \
|
||||
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 data.sig_ref import SIGRef
|
||||
|
||||
@@ -252,9 +251,16 @@ class Spot:
|
||||
if self.comment:
|
||||
sig_matches = re.finditer(r"(^|\W)" + ANY_SIG_REGEX + r"($|\W)", self.comment, re.IGNORECASE)
|
||||
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
|
||||
# spots where the comment is just "POTA".
|
||||
# See what SIG we think this is
|
||||
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:
|
||||
self.sig = found_sig
|
||||
|
||||
|
||||
@@ -1,12 +1,14 @@
|
||||
import json
|
||||
import logging
|
||||
import re
|
||||
import threading
|
||||
from datetime import datetime
|
||||
|
||||
import pytz
|
||||
import requests
|
||||
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.lookup_helper import infer_band_from_freq
|
||||
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.spot import Spot
|
||||
|
||||
RECAPTCHA_VERIFY_URL = "https://www.google.com/recaptcha/api/siteverify"
|
||||
|
||||
|
||||
class APISpotHandler(tornado.web.RequestHandler):
|
||||
"""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._web_server_metrics = web_server_metrics
|
||||
self._spot_providers = spot_providers or []
|
||||
|
||||
def post(self):
|
||||
try:
|
||||
@@ -58,15 +63,43 @@ class APISpotHandler(tornado.web.RequestHandler):
|
||||
self.set_header("Content-Type", "application/json")
|
||||
return
|
||||
|
||||
# Read in the request body as JSON then convert to a Spot object
|
||||
json_spot = tornado.escape.json_decode(post_data)
|
||||
spot = Spot(**json_spot)
|
||||
# Read in the request body as JSON
|
||||
json_body = tornado.escape.json_decode(post_data)
|
||||
|
||||
# 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
|
||||
# redo this in a functional style)
|
||||
if spot.sig_refs:
|
||||
if spot.sig and spot.sig_refs:
|
||||
real_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)))
|
||||
spot.sig_refs = real_sig_refs
|
||||
|
||||
@@ -126,13 +159,47 @@ class APISpotHandler(tornado.web.RequestHandler):
|
||||
self.set_header("Content-Type", "application/json")
|
||||
return
|
||||
|
||||
# infer missing data, and add it to our database.
|
||||
spot.source = "API"
|
||||
spot.infer_missing()
|
||||
self._spots.add(spot.id, spot, expire=MAX_SPOT_AGE)
|
||||
# Reject upstream submission if not permitted
|
||||
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
|
||||
|
||||
self.write(json.dumps("OK", default=serialize_everything))
|
||||
self.set_status(201)
|
||||
# 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()
|
||||
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.set_status(201)
|
||||
self.set_header("Cache-Control", "no-store")
|
||||
self.set_header("Content-Type", "application/json")
|
||||
|
||||
@@ -142,3 +209,23 @@ class APISpotHandler(tornado.web.RequestHandler):
|
||||
self.set_status(500)
|
||||
self.set_header("Cache-Control", "no-store")
|
||||
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
|
||||
|
||||
@@ -143,7 +143,7 @@ class APIAlertsStreamHandler(tornado_eventsource.handler.EventSourceHandler):
|
||||
if self._alert_queue not in self._sse_alert_queues:
|
||||
logging.error("Web server cleared up a queue of an active connection!")
|
||||
self.close()
|
||||
except:
|
||||
except Exception as e:
|
||||
logging.warning("Exception in SSE callback, connection will be closed: %s", e, exc_info=True)
|
||||
self.close()
|
||||
|
||||
|
||||
@@ -13,9 +13,10 @@ from core.utils import serialize_everything
|
||||
class APIOptionsHandler(tornado.web.RequestHandler):
|
||||
"""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._web_server_metrics = web_server_metrics
|
||||
self._spot_providers = spot_providers or []
|
||||
|
||||
def get(self):
|
||||
# Metrics
|
||||
@@ -24,6 +25,15 @@ class APIOptionsHandler(tornado.web.RequestHandler):
|
||||
self._web_server_metrics["status"] = "OK"
|
||||
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,
|
||||
"modes": ALL_MODES,
|
||||
"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"]))),
|
||||
"continents": CONTINENTS,
|
||||
"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
|
||||
# one of our proviers.
|
||||
if ALLOW_SPOTTING:
|
||||
|
||||
@@ -1,11 +1,9 @@
|
||||
import json
|
||||
from datetime import datetime
|
||||
|
||||
import pytz
|
||||
import tornado
|
||||
|
||||
from core.prometheus_metrics_handler import api_requests_counter
|
||||
from core.utils import serialize_everything
|
||||
|
||||
|
||||
class APISolarConditionsHandler(tornado.web.RequestHandler):
|
||||
|
||||
@@ -145,7 +145,7 @@ class APISpotsStreamHandler(tornado_eventsource.handler.EventSourceHandler):
|
||||
if self._spot_queue not in self._sse_spot_queues:
|
||||
logging.error("Web server cleared up a queue of an active connection!")
|
||||
self.close()
|
||||
except:
|
||||
except Exception as e:
|
||||
logging.warning("Exception in SSE callback, connection will be closed: %s", e, exc_info=True)
|
||||
self.close()
|
||||
|
||||
|
||||
@@ -22,7 +22,7 @@ from server.handlers.pagetemplate import PageTemplateHandler
|
||||
class WebServer:
|
||||
"""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"""
|
||||
|
||||
self._spots = spots
|
||||
@@ -31,6 +31,7 @@ class WebServer:
|
||||
self._sse_spot_queues = []
|
||||
self._sse_alert_queues = []
|
||||
self._status_data = status_data
|
||||
self._spot_providers = spot_providers or []
|
||||
self._port = WEB_SERVER_PORT
|
||||
self._api_only_mode = API_ONLY_MODE
|
||||
self._shutdown_event = asyncio.Event()
|
||||
@@ -69,12 +70,12 @@ class WebServer:
|
||||
{"sse_alert_queues": self._sse_alert_queues, **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/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/lookup/call", APILookupCallHandler, {**handler_opts}),
|
||||
(r"/api/v1/lookup/sigref", APILookupSIGRefHandler, {**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
|
||||
|
||||
@@ -98,18 +98,21 @@ if __name__ == '__main__':
|
||||
# Set up lookup helper
|
||||
lookup_helper.start()
|
||||
|
||||
# Set up web server
|
||||
web_server = WebServer(spots=spots, alerts=alerts, solar_conditions=solar_conditions, status_data=status_data)
|
||||
|
||||
# Fetch, set up and start spot providers
|
||||
# Create spot providers
|
||||
for entry in config["spot-providers"]:
|
||||
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:
|
||||
p.setup(spots=spots, web_server=web_server)
|
||||
if p.enabled:
|
||||
p.start()
|
||||
|
||||
# Fetch, set up and start alert providers
|
||||
# Create, set up and start alert providers
|
||||
for entry in config["alert-providers"]:
|
||||
alert_providers.append(get_alert_provider_from_config(entry))
|
||||
for p in alert_providers:
|
||||
@@ -117,7 +120,7 @@ if __name__ == '__main__':
|
||||
if p.enabled:
|
||||
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", []):
|
||||
solar_condition_providers.append(get_solar_conditions_provider_from_config(entry))
|
||||
for p in solar_condition_providers:
|
||||
|
||||
@@ -89,3 +89,11 @@ class GMA(HTTPSpotProvider):
|
||||
logging.warning("Exception when looking up " + self.REF_INFO_URL_ROOT + source_spot[
|
||||
"REF"] + ", ignoring this spot for now")
|
||||
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")
|
||||
|
||||
@@ -64,3 +64,10 @@ class HEMA(HTTPSpotProvider):
|
||||
# that for us.
|
||||
new_spots.append(spot)
|
||||
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")
|
||||
|
||||
@@ -19,6 +19,7 @@ class HTTPSpotProvider(SpotProvider):
|
||||
self._poll_interval = poll_interval
|
||||
self._thread = None
|
||||
self._stop_event = Event()
|
||||
self._wakeup_event = Event()
|
||||
|
||||
def start(self):
|
||||
# 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):
|
||||
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):
|
||||
while True:
|
||||
self._wakeup_event.clear()
|
||||
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
|
||||
|
||||
def _poll(self):
|
||||
|
||||
@@ -3,7 +3,9 @@ import re
|
||||
from datetime import datetime
|
||||
|
||||
import pytz
|
||||
import requests
|
||||
|
||||
from core.constants import HTTP_HEADERS
|
||||
from data.sig_ref import SIGRef
|
||||
from data.spot import Spot
|
||||
from spotproviders.http_spot_provider import HTTPSpotProvider
|
||||
@@ -14,7 +16,9 @@ class ParksNPeaks(HTTPSpotProvider):
|
||||
|
||||
POLL_INTERVAL_SEC = 120
|
||||
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"
|
||||
SUBMITTABLE_SIGS = ["POTA", "SOTA", "WWFF", "HEMA", "WOTA", "ZLOTA", "SIOTA", "KRMNPA"]
|
||||
|
||||
def __init__(self, provider_config):
|
||||
super().__init__(provider_config, self.SPOTS_URL, self.POLL_INTERVAL_SEC)
|
||||
@@ -62,3 +66,27 @@ class ParksNPeaks(HTTPSpotProvider):
|
||||
# Add new spot to the list
|
||||
new_spots.append(spot)
|
||||
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)
|
||||
|
||||
@@ -1,7 +1,9 @@
|
||||
from datetime import datetime
|
||||
|
||||
import pytz
|
||||
import requests
|
||||
|
||||
from core.constants import HTTP_HEADERS
|
||||
from data.sig_ref import SIGRef
|
||||
from data.spot import Spot
|
||||
from spotproviders.http_spot_provider import HTTPSpotProvider
|
||||
@@ -12,6 +14,7 @@ class POTA(HTTPSpotProvider):
|
||||
|
||||
POLL_INTERVAL_SEC = 120
|
||||
SPOTS_URL = "https://api.pota.app/spot/activator"
|
||||
SUBMIT_URL = "https://api.pota.app/spot"
|
||||
|
||||
def __init__(self, provider_config):
|
||||
super().__init__(provider_config, self.SPOTS_URL, self.POLL_INTERVAL_SEC)
|
||||
@@ -40,3 +43,25 @@ class POTA(HTTPSpotProvider):
|
||||
# that for us.
|
||||
new_spots.append(spot)
|
||||
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.")
|
||||
|
||||
@@ -2,7 +2,7 @@ from datetime import datetime
|
||||
|
||||
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.spot import Spot
|
||||
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
|
||||
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):
|
||||
super().__init__(provider_config, self.EPOCH_URL, self.POLL_INTERVAL_SEC)
|
||||
self._api_epoch = ""
|
||||
@@ -56,3 +59,45 @@ class SOTA(HTTPSpotProvider):
|
||||
# that for us.
|
||||
new_spots.append(spot)
|
||||
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.")
|
||||
|
||||
@@ -68,3 +68,20 @@ class SpotProvider:
|
||||
"""Stop any threads and prepare for application shutdown"""
|
||||
|
||||
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
|
||||
|
||||
@@ -1,5 +1,8 @@
|
||||
from datetime import datetime
|
||||
|
||||
import requests
|
||||
|
||||
from core.constants import HTTP_HEADERS, SSB_SUB_MODES
|
||||
from data.sig_ref import SIGRef
|
||||
from data.spot import Spot
|
||||
from spotproviders.http_spot_provider import HTTPSpotProvider
|
||||
@@ -10,6 +13,8 @@ class Tiles(HTTPSpotProvider):
|
||||
|
||||
POLL_INTERVAL_SEC = 120
|
||||
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):
|
||||
super().__init__(provider_config, self.SPOTS_URL, self.POLL_INTERVAL_SEC)
|
||||
@@ -41,6 +46,48 @@ class Tiles(HTTPSpotProvider):
|
||||
new_spots.append(spot)
|
||||
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'
|
||||
# strange frequency format where we can sometimes have e.g. "14.123.5".
|
||||
def strip_extra_decimal_points(s):
|
||||
|
||||
@@ -77,3 +77,10 @@ class WOTA(HTTPSpotProvider):
|
||||
except Exception as e:
|
||||
logging.error("Exception parsing WOTA spot", e)
|
||||
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")
|
||||
|
||||
@@ -41,3 +41,10 @@ class WWBOTA(SSESpotProvider):
|
||||
|
||||
# WWBOTA does support a special "Test" spot type, we need to avoid adding that.
|
||||
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")
|
||||
|
||||
@@ -38,3 +38,10 @@ class WWFF(HTTPSpotProvider):
|
||||
# that for us.
|
||||
new_spots.append(spot)
|
||||
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")
|
||||
|
||||
@@ -41,3 +41,10 @@ class ZLOTA(HTTPSpotProvider):
|
||||
|
||||
new_spots.append(spot)
|
||||
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")
|
||||
|
||||
@@ -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>
|
||||
</div>
|
||||
|
||||
<script src="/js/common.js?v=1780999608"></script>
|
||||
<script>$(document).ready(function() { $("#nav-link-about").addClass("active"); }); <!-- highlight active page in nav --></script>
|
||||
|
||||
{% end %}
|
||||
@@ -69,8 +69,7 @@
|
||||
|
||||
</div>
|
||||
|
||||
<script src="/js/common.js?v=1780999608"></script>
|
||||
<script src="/js/add-spot.js?v=1780999608"></script>
|
||||
<script src="/js/add-spot.js?v=1781811406"></script>
|
||||
<script>$(document).ready(function() { $("#nav-link-add-spot").addClass("active"); }); <!-- highlight active page in nav --></script>
|
||||
|
||||
{% end %}
|
||||
@@ -70,8 +70,7 @@
|
||||
|
||||
</div>
|
||||
|
||||
<script src="/js/common.js?v=1780999608"></script>
|
||||
<script src="/js/alerts.js?v=1780999608"></script>
|
||||
<script src="/js/alerts.js?v=1781811406"></script>
|
||||
<script>$(document).ready(function() { $("#nav-link-alerts").addClass("active"); }); <!-- highlight active page in nav --></script>
|
||||
|
||||
{% end %}
|
||||
@@ -1,7 +1,6 @@
|
||||
{% extends "skeleton.html" %}
|
||||
{% block head_extra %}
|
||||
<link href="https://cdn.jsdelivr.net/npm/bootstrap@5.3.8/dist/css/bootstrap.min.css" rel="stylesheet"
|
||||
integrity="sha384-sRIl4kxILFvY47J16cr9ZwB07vP4J8+LH7qKQnuqkuIAvNWLzeN8tE5YBujZqJLB" crossorigin="anonymous">
|
||||
<link href="/vendor/css/bootstrap-5.3.8.min.css" rel="stylesheet">
|
||||
{% end %}
|
||||
{% block body %}
|
||||
<div class="container mt-5">
|
||||
|
||||
@@ -76,9 +76,8 @@
|
||||
<script>
|
||||
let spotProvidersEnabledByDefault = {% raw json_encode(web_ui_options["spot-providers-enabled-by-default"]) %};
|
||||
</script>
|
||||
<script src="/js/common.js?v=1780999608"></script>
|
||||
<script src="/js/spotsbandsandmap.js?v=1780999608"></script>
|
||||
<script src="/js/bands.js?v=1780999608"></script>
|
||||
<script src="/js/spotsbandsandmap.js?v=1781811406"></script>
|
||||
<script src="/js/bands.js?v=1781811406"></script>
|
||||
<script>$(document).ready(function() { $("#nav-link-bands").addClass("active"); }); <!-- highlight active page in nav --></script>
|
||||
|
||||
{% end %}
|
||||
@@ -1,27 +1,19 @@
|
||||
{% extends "skeleton.html" %}
|
||||
{% block head_extra %}
|
||||
<link rel="stylesheet" href="/css/style.css?v=1780999608" type="text/css">
|
||||
<link href="https://cdn.jsdelivr.net/npm/bootstrap@5.3.8/dist/css/bootstrap.min.css" rel="stylesheet"
|
||||
integrity="sha384-sRIl4kxILFvY47J16cr9ZwB07vP4J8+LH7qKQnuqkuIAvNWLzeN8tE5YBujZqJLB" crossorigin="anonymous">
|
||||
<link href="/fa/css/fontawesome.min.css" rel="stylesheet" />
|
||||
<link href="/fa/css/solid.min.css" rel="stylesheet" />
|
||||
<link rel="stylesheet" href="/css/style.css?v=1781811406" type="text/css">
|
||||
<link href="/vendor/css/bootstrap-5.3.8.min.css" rel="stylesheet">
|
||||
<link href="/vendor/css/fontawesome-6.7.2.min.css" rel="stylesheet">
|
||||
<link href="/vendor/css/solid-6.7.2.min.css" rel="stylesheet">
|
||||
|
||||
<script src="https://cdn.jsdelivr.net/npm/jquery@3.7.1/dist/jquery.min.js"
|
||||
integrity="sha384-1H217gwSVyLSIfaLxHbE7dRb3v4mYCKbpQvzx0cegeju1MVsGrX5xXxAvs/HgeFs"
|
||||
crossorigin="anonymous"></script>
|
||||
<script src="https://cdn.jsdelivr.net/npm/moment@2.29.4/moment.min.js"
|
||||
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="/vendor/js/jquery-3.7.1.min.js"></script>
|
||||
<script src="/vendor/js/moment-2.29.4.min.js"></script>
|
||||
<script src="/vendor/js/bootstrap-5.3.8.bundle.min.js"></script>
|
||||
<script src="/vendor/js/tinycolor2-1.6.0.min.js"></script>
|
||||
|
||||
<script src="https://misc.ianrenton.com/jsutils/utils.js?v=1780999608"></script>
|
||||
<script src="https://misc.ianrenton.com/jsutils/ui-ham.js?v=1780999608"></script>
|
||||
<script src="https://misc.ianrenton.com/jsutils/geo.js?v=1780999608"></script>
|
||||
<script src="/js/utils.js?v=1781811406"></script>
|
||||
<script src="/js/ui-ham.js?v=1781811406"></script>
|
||||
<script src="/js/geo.js?v=1781811406"></script>
|
||||
<script src="/js/common.js?v=1781811406"></script>
|
||||
{% end %}
|
||||
{% block body %}
|
||||
<div class="container">
|
||||
|
||||
@@ -283,9 +283,8 @@
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<script src="https://cdn.jsdelivr.net/npm/chart.js@4.4.9/dist/chart.umd.min.js"></script>
|
||||
<script src="/js/common.js?v=1780999608"></script>
|
||||
<script src="/js/conditions.js?v=1780999608"></script>
|
||||
<script src="/vendor/js/chart-4.4.9.umd.min.js"></script>
|
||||
<script src="/js/conditions.js?v=1781811406"></script>
|
||||
<script>$(document).ready(function () {
|
||||
$("#nav-link-conditions").addClass("active");
|
||||
}); <!-- highlight active page in nav --></script>
|
||||
|
||||
@@ -76,27 +76,26 @@
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/leaflet@1.9.4/dist/leaflet.min.css">
|
||||
<link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/leaflet-extra-markers@1.2.2/dist/css/leaflet.extra-markers.min.css">
|
||||
<script src="https://cdn.jsdelivr.net/npm/leaflet@1.9.4/dist/leaflet.min.js"></script>
|
||||
<script src="https://cdn.jsdelivr.net/npm/overlapping-marker-spiderfier-leaflet/dist/oms.min.js"></script>
|
||||
<script src="https://cdn.jsdelivr.net/npm/leaflet-providers@2.0.0/leaflet-providers.min.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="https://cdn.jsdelivr.net/npm/leaflet.geodesic"></script>
|
||||
<script src="https://unpkg.com/leaflet.vectorgrid@latest/dist/Leaflet.VectorGrid.js"></script>
|
||||
<script src="https://cdn.jsdelivr.net/npm/text-image/dist/text-image.js"></script>
|
||||
<script src="https://cdn.jsdelivr.net/npm/@joergdietrich/leaflet.terminator@1.1.0/L.Terminator.min.js"></script>
|
||||
<script src="https://ianrenton.github.io/Leaflet.Maidenhead/src/L.Maidenhead.js"></script>
|
||||
<script src="https://ha8tks.github.io/Leaflet.ITUzones/src/L.ITUzones.js"></script>
|
||||
<script src="https://ha8tks.github.io/Leaflet.CQzones/src/L.CQzones.js"></script>
|
||||
<script src="https://misc.ianrenton.com/Leaflet.WorkedAllBritainIreland/L.WorkedAllBritainIreland.js"></script>
|
||||
<link rel="stylesheet" href="/vendor/css/leaflet-1.9.4.min.css">
|
||||
<link rel="stylesheet" href="/vendor/css/leaflet-extra-markers-1.2.2.min.css">
|
||||
<script src="/vendor/js/leaflet-1.9.4.min.js"></script>
|
||||
<script src="/vendor/js/oms-leaflet-0.2.7.min.js"></script>
|
||||
<script src="/vendor/js/leaflet-providers-2.0.0.js"></script>
|
||||
<script src="/vendor/js/leaflet-extra-markers-1.2.2.min.js"></script>
|
||||
<script src="/vendor/js/leaflet-geodesic-2.7.2.umd.min.js"></script>
|
||||
<script src="/vendor/js/leaflet-vectorgrid-1.3.0.js"></script>
|
||||
<script src="/vendor/js/text-image-0.7.0.js"></script>
|
||||
<script src="/vendor/js/leaflet-terminator-1.1.0.min.js"></script>
|
||||
<script src="/vendor/js/leaflet-maidenhead.js"></script>
|
||||
<script src="/vendor/js/leaflet-ituzones.js"></script>
|
||||
<script src="/vendor/js/leaflet-cqzones.js"></script>
|
||||
<script src="/vendor/js/leaflet-workedallbritainireland.js"></script>
|
||||
|
||||
<script>
|
||||
let spotProvidersEnabledByDefault = {% raw json_encode(web_ui_options["spot-providers-enabled-by-default"]) %};
|
||||
</script>
|
||||
<script src="/js/common.js?v=1780999608"></script>
|
||||
<script src="/js/spotsbandsandmap.js?v=1780999608"></script>
|
||||
<script src="/js/map.js?v=1780999608"></script>
|
||||
<script src="/js/spotsbandsandmap.js?v=1781811406"></script>
|
||||
<script src="/js/map.js?v=1781811406"></script>
|
||||
<script>$(document).ready(function() { $("#nav-link-map").addClass("active"); }); <!-- highlight active page in nav --></script>
|
||||
|
||||
{% end %}
|
||||
@@ -104,9 +104,8 @@
|
||||
<script>
|
||||
let spotProvidersEnabledByDefault = {% raw json_encode(web_ui_options["spot-providers-enabled-by-default"]) %};
|
||||
</script>
|
||||
<script src="/js/common.js?v=1780999608"></script>
|
||||
<script src="/js/spotsbandsandmap.js?v=1780999608"></script>
|
||||
<script src="/js/spots.js?v=1780999608"></script>
|
||||
<script src="/js/spotsbandsandmap.js?v=1781811406"></script>
|
||||
<script src="/js/spots.js?v=1781811406"></script>
|
||||
<script>$(document).ready(function() { $("#nav-link-spots").addClass("active"); }); <!-- highlight active page in nav --></script>
|
||||
|
||||
{% end %}
|
||||
@@ -59,8 +59,7 @@
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<script src="/js/common.js?v=1780999608"></script>
|
||||
<script src="/js/status.js?v=1780999608"></script>
|
||||
<script src="/js/status.js?v=1781811406"></script>
|
||||
<script>
|
||||
$(document).ready(function() { $("#nav-link-status").addClass("active"); }); <!-- highlight active page in nav -->
|
||||
</script>
|
||||
|
||||
@@ -15,6 +15,12 @@ info:
|
||||
|
||||
## 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
|
||||
|
||||
* `/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:
|
||||
name: The Unlicense
|
||||
url: https://unlicense.org/#the-unlicense
|
||||
version: v1.3
|
||||
version: 1.4
|
||||
|
||||
servers:
|
||||
- url: https://spothole.app/api/v1
|
||||
@@ -288,7 +294,8 @@ paths:
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: '#/components/schemas/ErrorResponse'
|
||||
type: string
|
||||
example: "Failed"
|
||||
|
||||
|
||||
/lookup/sigref:
|
||||
@@ -313,7 +320,8 @@ paths:
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: '#/components/schemas/ErrorResponse'
|
||||
type: string
|
||||
example: "Failed"
|
||||
|
||||
|
||||
|
||||
@@ -339,7 +347,8 @@ paths:
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: '#/components/schemas/ErrorResponse'
|
||||
type: string
|
||||
example: "Failed"
|
||||
|
||||
|
||||
/spot:
|
||||
@@ -347,40 +356,44 @@ paths:
|
||||
tags:
|
||||
- Spots
|
||||
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
|
||||
requestBody:
|
||||
description: The JSON spot object
|
||||
description: The JSON spot object, plus optional upstream submission control fields
|
||||
required: true
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: '#/components/schemas/Spot'
|
||||
$ref: '#/components/schemas/SpotSubmission'
|
||||
responses:
|
||||
'200':
|
||||
'201':
|
||||
description: Success
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: '#/components/schemas/OkResponse'
|
||||
type: string
|
||||
example: "OK"
|
||||
'415':
|
||||
description: Incorrect Content-Type
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: '#/components/schemas/ErrorResponse'
|
||||
type: string
|
||||
example: "Failed"
|
||||
'422':
|
||||
description: Validation error
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: '#/components/schemas/ErrorResponse'
|
||||
type: string
|
||||
example: "Failed"
|
||||
'500':
|
||||
description: Internal server error
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: '#/components/schemas/ErrorResponse'
|
||||
type: string
|
||||
example: "Failed"
|
||||
|
||||
components:
|
||||
parameters:
|
||||
@@ -982,6 +995,48 @@ components:
|
||||
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:
|
||||
type: object
|
||||
description: A server-sent event containing a spot
|
||||
@@ -1294,7 +1349,7 @@ components:
|
||||
solar_storm_forecast:
|
||||
type: object
|
||||
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.
|
||||
Values are integer percentages (0–100).
|
||||
additionalProperties:
|
||||
@@ -1308,7 +1363,7 @@ components:
|
||||
blackout_forecast_r1r2:
|
||||
type: object
|
||||
description: >
|
||||
NOAA Radio Blackout forecast — probability (%) of R1–R2 (Minor–Moderate) blackout events
|
||||
NOAA Radio Blackout forecast containing probability (%) of R1–R2 (Minor–Moderate) blackout events
|
||||
per day. Keys are UNIX timestamps (UTC seconds since epoch) for the start of each
|
||||
forecast day. Values are integer percentages (0–100).
|
||||
additionalProperties:
|
||||
@@ -1322,7 +1377,7 @@ components:
|
||||
blackout_forecast_r3_or_greater:
|
||||
type: object
|
||||
description: >
|
||||
NOAA Radio Blackout forecast — probability (%) of R3 or greater (Strong–Extreme) blackout
|
||||
NOAA Radio Blackout forecast containing probability (%) of R3 or greater (Strong–Extreme) blackout
|
||||
events per day. Keys are UNIX timestamps (UTC seconds since epoch) for the start of each
|
||||
forecast day. Values are integer percentages (0–100).
|
||||
additionalProperties:
|
||||
@@ -1493,14 +1548,6 @@ components:
|
||||
items:
|
||||
$ref: '#/components/schemas/Alert'
|
||||
|
||||
OkResponse:
|
||||
type: string
|
||||
example: "OK"
|
||||
|
||||
ErrorResponse:
|
||||
type: string
|
||||
example: "Failed"
|
||||
|
||||
DxStats:
|
||||
type: object
|
||||
description: Spot counts keyed by DE continent
|
||||
@@ -1637,6 +1684,20 @@ components:
|
||||
type: boolean
|
||||
description: Whether the POST /spot call, to add spots to the server directly via its API, is permitted on this server.
|
||||
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:
|
||||
type: object
|
||||
|
||||
@@ -184,7 +184,7 @@ tr.new td {
|
||||
background-color: var(--bs-success-border-subtle);
|
||||
}
|
||||
100% {
|
||||
background-color: intial;
|
||||
background-color: initial;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -355,7 +355,7 @@ div.band-spot:hover span.band-spot-info {
|
||||
|
||||
/* GENERAL MOBILE SUPPORT */
|
||||
|
||||
@media (max-width: 991.99px) {
|
||||
@media (max-width: 991px) {
|
||||
/* General "hide this on mobile" class */
|
||||
.hideonmobile {
|
||||
display: none !important;
|
||||
|
||||
@@ -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
|
||||
// any saved values from local storage.
|
||||
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
|
||||
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
|
||||
function addSpot() {
|
||||
try {
|
||||
@@ -46,6 +203,7 @@ function addSpot() {
|
||||
if (dx != "") {
|
||||
spot["dx_call"] = dx;
|
||||
} 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.");
|
||||
return;
|
||||
}
|
||||
@@ -78,21 +236,73 @@ function addSpot() {
|
||||
}
|
||||
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", {
|
||||
data : JSON.stringify(spot),
|
||||
contentType : 'application/json',
|
||||
type : 'POST',
|
||||
timeout: 10000,
|
||||
success: async function (result) {
|
||||
$("#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>");
|
||||
// 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-bad").html("");
|
||||
setTimeout(() => {
|
||||
$("#result-good").hide();
|
||||
window.location.replace("/");
|
||||
}, 1000);
|
||||
}, 2000);
|
||||
},
|
||||
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) {
|
||||
@@ -121,20 +331,18 @@ $("#mode").change(function () {
|
||||
$(this).val($(this).val().trim().toUpperCase());
|
||||
});
|
||||
|
||||
// Display the intro box, unless the user has already dismissed it once.
|
||||
function displayIntroBox() {
|
||||
if (localStorage.getItem("add-spot-intro-box-dismissed") == null) {
|
||||
$("#add-spot-intro-box").show();
|
||||
}
|
||||
$("#add-spot-intro-box-dismiss").click(function() {
|
||||
localStorage.setItem("add-spot-intro-box-dismissed", true);
|
||||
});
|
||||
}
|
||||
// Update upstream area and credentials button when SIG changes
|
||||
$("#sig").change(function () {
|
||||
updateUpstreamArea();
|
||||
});
|
||||
|
||||
// Update credentials button when provider selector changes
|
||||
$("#upstream-provider-select").change(function () {
|
||||
updateCredentialsButton();
|
||||
});
|
||||
|
||||
// Startup
|
||||
$(document).ready(function() {
|
||||
// Load options
|
||||
loadOptions();
|
||||
// Display intro box
|
||||
displayIntroBox();
|
||||
});
|
||||
@@ -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];
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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 '&';
|
||||
case '<': return '<';
|
||||
case '>': return '>';
|
||||
case '"': return '"';
|
||||
case '\'': return ''';
|
||||
case '`': return '`';
|
||||
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));
|
||||
}
|
||||
|
After Width: | Height: | Size: 1.2 KiB |
|
After Width: | Height: | Size: 696 B |
|
After Width: | Height: | Size: 2.4 KiB |
|
After Width: | Height: | Size: 1.4 KiB |
|
After Width: | Height: | Size: 618 B |
|
After Width: | Height: | Size: 117 KiB |
|
After Width: | Height: | Size: 248 KiB |
|
After Width: | Height: | Size: 535 B |
|
After Width: | Height: | Size: 1.4 KiB |
@@ -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);
|
||||
};
|
||||
@@ -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
|
||||
@@ -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);
|
||||
};
|
||||
@@ -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}}();
|
||||