mirror of
https://git.ianrenton.com/ian/spothole.git
synced 2025-12-13 07:33:39 +00:00
Compare commits
21 Commits
0c8973bbc6
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
b00b4130c5 | ||
|
|
b3be6b5ca4 | ||
|
|
210a0564aa | ||
|
|
03af6858b4 | ||
|
|
e86d6b8c28 | ||
|
|
9d130712d8 | ||
|
|
8a82f81ec4 | ||
|
|
ca31d23b4a | ||
|
|
8a4f23ac72 | ||
|
|
3da8c80ad6 | ||
|
|
0fa8b44c9c | ||
|
|
4aa7b91092 | ||
|
|
e7469db99e | ||
|
|
9d9f4609f0 | ||
|
|
368e69bf00 | ||
|
|
9bdd0ab1de | ||
|
|
255719f3b5 | ||
|
|
f21ea0ae5d | ||
|
|
2be2af176c | ||
|
|
583735c99f | ||
|
|
6c9f3136b8 |
85
README.md
85
README.md
@@ -10,17 +10,62 @@ The API is deliberately well-defined with an OpenAPI specification and auto-gene
|
||||
|
||||
Spothole itself is also open source, Public Domain licenced code that anyone can take and modify.
|
||||
|
||||
Supported data sources include DX Clusters, the Reverse Beacon Network (RBN), the APRS Internet Service (APRS-IS), POTA, SOTA, WWFF, GMA, WWBOTA, HEMA, Parks 'n' Peaks, ZLOTA, WOTA, BOTA, the UK Packet Repeater Network, and NG3K.
|
||||
Supported data sources include DX Clusters, the Reverse Beacon Network (RBN), the APRS Internet Service (APRS-IS), POTA, SOTA, WWFF, GMA, WWBOTA, HEMA, Parks 'n' Peaks, ZLOTA, WOTA, BOTA, the UK Packet Repeater Network, NG3K, and any site based on the xOTA software by nischu.
|
||||
|
||||

|
||||
|
||||

|
||||
|
||||
### Accessing the public version
|
||||
## Accessing the public version
|
||||
|
||||
You can access the public version's web interface at [https://spothole.app](https://spothole.app), and see [https://spothole.app/apidocs](https://spothole.app/apidocs) for the API details.
|
||||
|
||||
### Running your own copy
|
||||
This is a Progressive Web App, so you can also "install" it to your Android or iOS device by accessing it in Chrome or Safari respectively, and following the menu-driven process for installing PWAs.
|
||||
|
||||
## Embedding Spothole in another website
|
||||
|
||||
You can embed Spothole in another website, e.g. for use as part of a ham radio custom dashboard.
|
||||
|
||||
URL parameters can be used to trigger an "embedded" mode which hides the headers, footers and settings. In this mode, you provide configuration for the various filter and display options via additional URL parameters. Any settings that the user has set for Spothole are ignored. This is so that the embedding site can select, for example, their choice of dark mode or SIG filters, which will not impact how Spothole appears when the user accesses it directly. Effectively, it becomes separate to their normal Spothole settings.
|
||||
|
||||
Setting `embedded` to true is important for the rest of the settings to be applied; otherwise, the user's defaults will be used in preference to the URL params.
|
||||
|
||||
These are supplied with the URL to the page you want to embed, for example for an embedded version of the band map in dark mode, use `https://spothole.com/bands?embedded=true&dark-mode=true`. For an embedded version of the main spots/home page in the system light/dark mode, use `https://spothole.com/?embedded=true`. For dark mode showing 70cm TOTA spots only, use `https://spothole.com/?embedded=true&dark-mode=true&filter-sigs=TOTA&filter-bands=70cm`. Providing no URL params causes the page to be loaded in the normal way it would when accessed directly in the user's browser.
|
||||
|
||||
The supported parameters are as follows. Generally these match the equivalent parameters in the real Spothole API, where a mapping exists.
|
||||
|
||||
| Name | Allowed Values | Default | Example | Description |
|
||||
|----------------|-----------------------|---------|-------------------------|-----------------------------------------------------------------------------------------------------------------------------------------------------------------------|
|
||||
| `embedded` | `true`, `false` | `false` | `?embedded=true` | Enables embedded mode. |
|
||||
| `dark-mode` | `true`, `false` | `false` | `?dark-mode=true` | Enables dark mode. |
|
||||
| `time-zone` | `UTC`, `local` | `UTC` | `?time-zone=local` | Sets times to be in UTC or local time. |
|
||||
| `limit` | 10, 25, 50, 100 | 50 | `?limit=50` | Sets the number of spots that will be displayed on the main spots page |
|
||||
| `limit` | 25, 50, 100, 200, 500 | 100 | `?limit=100` | Sets the number of alerts that will be displayed on the alerts page |
|
||||
| `max_age` | 300, 600, 1800, 3600 | 1800 | `?max_age=1800` | Sets the maximum age of spots displayed on the map and bands pages, in seconds. |
|
||||
| `band` | Comma-separated list | (all) | `?band=20m,40m` | Sets the list of bands that will be shown on the spots, bands and map pages. Available options match the labels of the buttons in the standard web interface. |
|
||||
| `sig` | Comma-separated list | (all) | `?sig=POTA,SOTA,NO_SIG` | Sets the list of SIGs that will be shown on the spots, bands and map pages. Available options match the labels of the buttons in the standard web interface. |
|
||||
| `source` | Comma-separated list | (all) | `?source=Cluster` | Sets the list of sources that will be shown on any spot or alert pages. Available options match the labels of the buttons in the standard web interface. |
|
||||
| `mode_type` | Comma-separated list | (all) | `?mode_type=PHONE,CW` | Sets the list of mode types that will be shown on the spots, bands and map pages. Available options match the labels of the buttons in the standard web interface. |
|
||||
| `dx_continent` | Comma-separated list | (all) | `?dx_continent=NA,SA` | Sets the list of DX Continents that will be shown on any spot or alert pages. Available options match the labels of the buttons in the standard web interface. |
|
||||
| `de_continent` | Comma-separated list | (all) | `?de_continent=EU` | Sets the list of DE Continents that will be shown on the spots, bands and map pages. Available options match the labels of the buttons in the standard web interface. |
|
||||
|
||||
More will be added soon to allow customisation of filters and other display properties.
|
||||
|
||||
## Writing your own client
|
||||
|
||||
One of the key strengths of Spothole is that the API is well-defined and open to anyone to use. This means you can build your own software that uses data from Spothole.
|
||||
|
||||
Various approaches exist to writing your own client, but in general:
|
||||
|
||||
* Refer to the API docs. These are built on an OpenAPI definition file (`/webassets/apidocs/openapi.yml`), which you can automatically use to generate a client skeleton using various software.
|
||||
* Call the main "spots" or "alerts" API endpoints to get the data you want. Apply filters if necessary.
|
||||
* Call the "options" API to get an idea of which bands, modes etc. the server knows about. You might want to do that first before calling the spots/alerts APIs, to allow you to populate your filters correctly.
|
||||
* Refer to the provided HTML/JS interface for a reference
|
||||
* Let me know if you get stuck, I'm happy to help!
|
||||
|
||||
## Running your own copy
|
||||
|
||||
If you want to run a copy of Spothole with different configuration settings than the main instance, you can download it and run it on your own local machine or server.
|
||||
|
||||
To download and set up Spothole on a Debian server, run the following commands. Other operating systems will likely be similar.
|
||||
|
||||
@@ -34,7 +79,7 @@ deactivate
|
||||
cp config-example.yml config.yml
|
||||
```
|
||||
|
||||
Then edit `config.yml` in your text editor of choice to set up the software as you like it.
|
||||
Then edit `config.yml` in your text editor of choice to set up the software as you like it. Mostly, this will involve enabling or disabling the various providers of spot and alert data.
|
||||
|
||||
`config.yml` has some entries for QRZ.com username & password, and Clublog API keys. If provided, these allow Spothole to retrieve more information about DX spots, such as the country their callsign corresponds to. The software will work just fine without them, but you may find a few country flags etc. are less accurate or missing.
|
||||
|
||||
@@ -57,6 +102,8 @@ If you see some errors on startup, check your configuration, e.g. in case you ha
|
||||
|
||||
### systemd configuration
|
||||
|
||||
If you want Spothole to run automatically on startup on a Linux distribution that uses `systemd`, follow the instructions here. For distros that don't use `systemd`, or Windows/OSX/etc., you can find generic instructions for your OS online.
|
||||
|
||||
Create a file at `/etc/systemd/system/spothole.service`. Give it the following content, adjusting for the user you want to run it as and the directory in which you have installed it:
|
||||
|
||||
```
|
||||
@@ -87,7 +134,9 @@ Check the service has started up correctly with `sudo journalctl -u spothole -f`
|
||||
|
||||
### nginx Reverse Proxy configuration
|
||||
|
||||
It's best not to serve Spothole directly on port 80, as that requires root privileges and prevents us using HTTPS, amongst other reasons. To set up nginx as a reverse proxy that sits in front of Spothole, first ensure it's installed e.g. `sudo apt install nginx`, and enabled e.g. `sudo systemd enable nginx`.
|
||||
Web servers generally serve their pages from port 80. However, it's best not to serve Spothole's web interface directly on port 80, as that requires root privileges on a Linux system. It also and prevents us using HTTPS to serve a secure site, since Spothole itself doesn't directly support acting as an HTTPS server. The normal solution to this is to use a "reverse proxy" setup, where a general web server handles HTTP and HTTP requests (to port 80 & 443 respectively), then passes on the request to the back-end application (in this case Spothole). nginx is a common choice for this general web server.
|
||||
|
||||
To set up nginx as a reverse proxy that sits in front of Spothole, first ensure it's installed e.g. `sudo apt install nginx`, and enabled e.g. `sudo systemd enable nginx`.
|
||||
|
||||
Create a file at `/etc/nginx/sites-available/` called `spothole`. Give it the following contents, replacing `spothole.app` with the domain name on which you want to run Spothole. If you changed the port on which Spothole runs, update that on the "proxy_pass" line too.
|
||||
|
||||
@@ -135,17 +184,11 @@ You should now be able to access the web interface by going to the domain from y
|
||||
|
||||
Once that's working, [install certbot](https://certbot.eff.org/instructions?ws=nginx&os=snap) onto your server. Run it as root, and when prompted pick your domain name from the list. After a few seconds, it should successfully provision a certificate and modify your nginx config files automatically. You should then be able to access the site via HTTPS.
|
||||
|
||||
### Writing your own client
|
||||
## Modifying the source code
|
||||
|
||||
Various approaches exist to writing your own client, but in general:
|
||||
Spothole is Public Domain licenced, so you can grab the source code and start modifying it for your own needs. Contributions of code back to the main repository are encouraged, but completely optional.
|
||||
|
||||
* Refer to the API docs. These are built on an OpenAPI definition file (`/webassets/apidocs/openapi.yml`), which you can automatically use to generate a client skeleton using various software.
|
||||
* Call the main "spots" API to get the data you want. Apply filters if necessary.
|
||||
* Call the "options" API to get an idea of which bands, modes etc. the server knows about. You might want to do that first before calling the spots API.
|
||||
* Refer to the provided HTML/JS interface for a reference
|
||||
* Let me know if you get stuck, I'm happy to help!
|
||||
|
||||
### Structure of the source code
|
||||
### Code structure
|
||||
|
||||
To navigate your way around the source code, this list may help.
|
||||
|
||||
@@ -178,21 +221,23 @@ To navigate your way around the source code, this list may help.
|
||||
|
||||
### Extending the server
|
||||
|
||||
Spothole is designed to be easily extensible. If you want to write your own provider, simply add a module to the `providers` package containing your class. (Currently, in order to be loaded correctly, the module (file) name should be the same as the class name, but lower case.)
|
||||
Spothole is designed to be easily extensible. If you want to write your own spot provider, for example, simply add a module to the `spotproviders` package containing your class. (Currently, in order to be loaded correctly, the module (file) name should be the same as the class name, but lower case.)
|
||||
|
||||
Your class should extend "Provider"; if it operates by polling an HTTP Server on a timer, it can instead extend "HTTPProvider" where some of the work is done for you.
|
||||
Your class should extend "SpotProvider"; if it operates by polling an HTTP Server on a timer, it can instead extend "HTTPSpotProvider" where some of the work is done for you.
|
||||
|
||||
The class will need to implement a constructor that takes in the `provider_config` and provides it to the superclass constructor, while also taking any other config parameters it needs.
|
||||
|
||||
If you're extending the base `Provider` class, you will need to implement `start()` and `stop()` methods that start and stop a separate thread which handles the provider's processing needs. The thread should call `submit()` or `submit_batch()` when it has one or more spots to report.
|
||||
If you're extending the base `SpotProvider` class, you will need to implement `start()` and `stop()` methods that start and stop a separate thread which handles the provider's processing needs. The thread should call `submit()` or `submit_batch()` when it has one or more spots to report.
|
||||
|
||||
If you're extending the `HTTPProvider` class, you will need to provide a URI to query and an interval to the superclass constructor. You'll then need to implement the `http_response_to_spots()` method which is called when new data is retrieved. Your implementation should then call `submit()` or `submit_batch()` when it has one or more spots to report.
|
||||
If you're extending the `HTTPSpotProvider` class, you will need to provide a URI to query and an interval to the superclass constructor. You'll then need to implement the `http_response_to_spots()` method which is called when new data is retrieved. Your implementation should then call `submit()` or `submit_batch()` when it has one or more spots to report.
|
||||
|
||||
When constructing spots, use the comments in the Spot class and the existing implementations as an example. All parameters are optional, but you will at least want to provide a `time` (which must be timezone-aware) and a `dx_call`.
|
||||
|
||||
Finally, simply add the appropriate config to the `providers` section of `config.yml`, and your provider should be instantiated on startup.
|
||||
Finally, simply add the appropriate config to the `spot_providers` section of `config.yml`, and your provider should be instantiated on startup.
|
||||
|
||||
### Thanks
|
||||
The same approach as above is also used for alert providers.
|
||||
|
||||
## Thanks
|
||||
|
||||
As well as being my work, I have also gratefully received feature patches from Steven, M1SDH.
|
||||
|
||||
|
||||
@@ -81,6 +81,18 @@ spot-providers:
|
||||
class: "UKPacketNet"
|
||||
name: "UK Packet Radio Net"
|
||||
enabled: false
|
||||
-
|
||||
class: "XOTA"
|
||||
name: "39C3 TOTA"
|
||||
enabled: false
|
||||
url: "https://39c3.c3nav.de/"
|
||||
# Fixed SIG/latitude/longitude for all spots from a provider is currently only a feature for the "XOTA" provider,
|
||||
# the software found at https://github.com/nischu/xOTA/. This is because this is a generic backend for xOTA
|
||||
# programmes and so different URLs provide different programmes.
|
||||
sig: "TOTA"
|
||||
latitude: 53.5622678
|
||||
longitude: 9.9855205
|
||||
|
||||
|
||||
# Alert providers to use. Same setup as the spot providers list above.
|
||||
alert-providers:
|
||||
|
||||
@@ -29,16 +29,27 @@ class CleanupTimer:
|
||||
# Perform cleanup and reschedule next timer
|
||||
def cleanup(self):
|
||||
try:
|
||||
# Perform cleanup
|
||||
# Perform cleanup via letting the data expire
|
||||
self.spots.expire()
|
||||
self.alerts.expire()
|
||||
|
||||
# Alerts can persist in the system for a while, so we want to explicitly clean up any alerts that have
|
||||
# expired
|
||||
# Explicitly clean up any spots and alerts that have expired
|
||||
for id in list(self.spots.iterkeys()):
|
||||
try:
|
||||
spot = self.spots[id]
|
||||
if spot.expired():
|
||||
self.spots.delete(id)
|
||||
except KeyError:
|
||||
# Must have already been deleted, OK with that
|
||||
pass
|
||||
for id in list(self.alerts.iterkeys()):
|
||||
alert = self.alerts[id]
|
||||
if alert.expired():
|
||||
self.alerts.delete(id)
|
||||
try:
|
||||
alert = self.alerts[id]
|
||||
if alert.expired():
|
||||
self.alerts.delete(id)
|
||||
except KeyError:
|
||||
# Must have already been deleted, OK with that
|
||||
pass
|
||||
|
||||
self.status = "OK"
|
||||
self.last_cleanup_time = datetime.now(pytz.UTC)
|
||||
|
||||
@@ -29,7 +29,8 @@ SIGS = [
|
||||
SIG(name="BOTA", description="Beaches on the Air", icon="water"),
|
||||
SIG(name="KRMNPA", description="Keith Roget Memorial National Parks Award", icon="earth-oceania"),
|
||||
SIG(name="WAB", description="Worked All Britain", icon="table-cells-large", ref_regex=r"[A-Z]{1,2}[0-9]{2}"),
|
||||
SIG(name="WAI", description="Worked All Ireland", icon="table-cells-large", ref_regex=r"[A-Z][0-9]{2}")
|
||||
SIG(name="WAI", description="Worked All Ireland", icon="table-cells-large", ref_regex=r"[A-Z][0-9]{2}"),
|
||||
SIG(name="TOTA", description="Toilets on the Air", icon="toilet", ref_regex=r"T\-[0-9]{2}")
|
||||
]
|
||||
|
||||
# Modes. Note "DIGI" and "DIGITAL" are also supported but are normalised into "DATA".
|
||||
|
||||
@@ -455,6 +455,11 @@ class LookupHelper:
|
||||
# QRZ had no info for the call, that's OK. Cache a None so we don't try to look this up again
|
||||
self.QRZ_CALLSIGN_DATA_CACHE.add(call, None, expire=604800) # 1 week in seconds
|
||||
return None
|
||||
except (Exception):
|
||||
# General exception like a timeout when communicating with QRZ. Return None this time, but don't cache
|
||||
# that, so we can try again next time.
|
||||
logging.error("Exception when looking up QRZ data")
|
||||
return None
|
||||
else:
|
||||
return None
|
||||
|
||||
|
||||
@@ -137,7 +137,7 @@ class Alert:
|
||||
return json.dumps(self, default=lambda o: o.__dict__, sort_keys=True)
|
||||
|
||||
# Decide if this alert has expired (in which case it should not be added to the system in the first place, and not
|
||||
# returned by the web server if later requested, and removed by the cleanup functions. "Expired" is defined as
|
||||
# returned by the web server if later requested, and removed by the cleanup functions). "Expired" is defined as
|
||||
# either having an end_time in the past, or if it only has a start_time, then that start time was more than 3 hours
|
||||
# ago. If it somehow doesn't have a start_time either, it is considered to be expired.
|
||||
def expired(self):
|
||||
|
||||
35
data/spot.py
35
data/spot.py
@@ -4,11 +4,12 @@ import json
|
||||
import logging
|
||||
import re
|
||||
from dataclasses import dataclass
|
||||
from datetime import datetime
|
||||
from datetime import datetime, timedelta
|
||||
|
||||
import pytz
|
||||
from pyhamtools.locator import locator_to_latlong, latlong_to_locator
|
||||
|
||||
from core.config import MAX_SPOT_AGE
|
||||
from core.lookup_helper import lookup_helper
|
||||
from core.sig_utils import get_icon_for_sig, get_sig_ref_info, ANY_SIG_REGEX, get_ref_regex_for_sig
|
||||
from data.sig_ref import SIGRef
|
||||
@@ -243,13 +244,15 @@ class Spot:
|
||||
if not self.sig and self.sig_refs and len(self.sig_refs) > 0:
|
||||
self.sig = self.sig_refs[0].sig.upper()
|
||||
|
||||
# See if we already have a SIG reference, but the comment looks like it contains more for the same SIG. This
|
||||
# See if we already have a SIG reference, but the comment looks like it contains more for the same SIG. This
|
||||
# should catch e.g. POTA comments like "2-fer: GB-0001 GB-0002".
|
||||
if self.comment and self.sig_refs and len(self.sig_refs) > 0:
|
||||
if self.comment and self.sig_refs and len(self.sig_refs) > 0 and self.sig_refs[0].sig:
|
||||
sig = self.sig_refs[0].sig.upper()
|
||||
all_comment_ref_matches = re.finditer(r"(^|\W)(" + get_ref_regex_for_sig(sig) + r")(^|\W)", self.comment, re.IGNORECASE)
|
||||
for ref_match in all_comment_ref_matches:
|
||||
self.append_sig_ref_if_missing(SIGRef(id=ref_match.group(2).upper(), sig=sig))
|
||||
regex = get_ref_regex_for_sig(sig)
|
||||
if regex:
|
||||
all_comment_ref_matches = re.finditer(r"(^|\W)(" + regex + r")(^|\W)", self.comment, re.IGNORECASE)
|
||||
for ref_match in all_comment_ref_matches:
|
||||
self.append_sig_ref_if_missing(SIGRef(id=ref_match.group(2).upper(), sig=sig))
|
||||
|
||||
# See if the comment looks like it contains any SIGs (and optionally SIG references) that we can
|
||||
# add to the spot. This should catch cluster spot comments like "POTA GB-0001 WWFF GFF-0001" and e.g. POTA
|
||||
@@ -300,6 +303,10 @@ class Spot:
|
||||
if self.sig:
|
||||
self.icon = get_icon_for_sig(self.sig)
|
||||
|
||||
# Default "radio" icon if nothing else has set it
|
||||
if not self.icon:
|
||||
self.icon = "tower-cell"
|
||||
|
||||
# DX Grid to lat/lon and vice versa in case one is missing
|
||||
if self.dx_grid and not self.dx_latitude:
|
||||
ll = locator_to_latlong(self.dx_grid)
|
||||
@@ -348,9 +355,10 @@ class Spot:
|
||||
|
||||
# DX Location is "good" if it is from a spot, or from QRZ if the callsign doesn't contain a slash, so the operator
|
||||
# is likely at home.
|
||||
self.dx_location_good = (self.dx_location_source == "SPOT" or self.dx_location_source == "SIG REF LOOKUP"
|
||||
or self.dx_location_source == "WAB/WAI GRID"
|
||||
or (self.dx_location_source == "HOME QTH" and not "/" in self.dx_call))
|
||||
self.dx_location_good = self.dx_latitude and self.dx_longitude and (
|
||||
self.dx_location_source == "SPOT" or self.dx_location_source == "SIG REF LOOKUP"
|
||||
or self.dx_location_source == "WAB/WAI GRID"
|
||||
or (self.dx_location_source == "HOME QTH" and not "/" in self.dx_call))
|
||||
|
||||
# DE with no digits and APRS servers starting "T2" are not things we can look up location for
|
||||
if self.de_call and any(char.isdigit() for char in self.de_call) and not (self.de_call.startswith("T2") and self.source == "APRS-IS"):
|
||||
@@ -380,7 +388,7 @@ class Spot:
|
||||
self_copy.received_time_iso = ""
|
||||
self.id = hashlib.sha256(str(self_copy).encode("utf-8")).hexdigest()
|
||||
|
||||
# JSON serialise
|
||||
# JSON sspoterialise
|
||||
def to_json(self):
|
||||
return json.dumps(self, default=lambda o: o.__dict__, sort_keys=True)
|
||||
|
||||
@@ -396,3 +404,10 @@ class Spot:
|
||||
if sig_ref.id == new_sig_ref.id and sig_ref.sig == new_sig_ref.sig:
|
||||
return
|
||||
self.sig_refs.append(new_sig_ref)
|
||||
|
||||
# Decide if this spot has expired (in which case it should not be added to the system in the first place, and not
|
||||
# returned by the web server if later requested, and removed by the cleanup functions). "Expired" is defined as
|
||||
# either having a time further ago than the server's MAX_SPOT_AGE. If it somehow doesn't have a time either, it is
|
||||
# considered to be expired.
|
||||
def expired(self):
|
||||
return not self.time or self.time < (datetime.now(pytz.UTC) - timedelta(seconds=MAX_SPOT_AGE)).timestamp()
|
||||
@@ -268,8 +268,6 @@ class WebServer:
|
||||
|
||||
# infer missing data, and add it to our database.
|
||||
spot.source = "API"
|
||||
if not spot.sig:
|
||||
spot.icon = "desktop"
|
||||
spot.infer_missing()
|
||||
self.spots.add(spot.id, spot, expire=MAX_SPOT_AGE)
|
||||
|
||||
@@ -338,11 +336,14 @@ class WebServer:
|
||||
sources = query.get(k).split(",")
|
||||
spots = [s for s in spots if s.source and s.source in sources]
|
||||
case "sig":
|
||||
# If a list of sigs is provided, the spot must have a sig and it must match one of them
|
||||
# If a list of sigs is provided, the spot must have a sig and it must match one of them.
|
||||
# The special "sig" "NO_SIG", when supplied in the list, mathches spots with no sig.
|
||||
sigs = query.get(k).split(",")
|
||||
spots = [s for s in spots if s.sig and s.sig in sigs]
|
||||
include_no_sig = "NO_SIG" in sigs
|
||||
spots = [s for s in spots if (s.sig and s.sig in sigs) or (include_no_sig and not s.sig)]
|
||||
case "needs_sig":
|
||||
# If true, a sig is required, regardless of what it is, it just can't be missing.
|
||||
# If true, a sig is required, regardless of what it is, it just can't be missing. Mutually
|
||||
# exclusive with supplying the special "NO_SIG" parameter to the "sig" query param.
|
||||
needs_sig = query.get(k).upper() == "TRUE"
|
||||
if needs_sig:
|
||||
spots = [s for s in spots if s.sig]
|
||||
@@ -421,7 +422,6 @@ class WebServer:
|
||||
if a is not None:
|
||||
alerts.append(a)
|
||||
# We never want alerts that seem to be in the past
|
||||
alerts = list(filter(lambda alert: not alert.expired(), alerts))
|
||||
alerts = sorted(alerts, key=lambda alert: (alert.start_time if alert and alert.start_time else 0))
|
||||
for k in query.keys():
|
||||
match k:
|
||||
@@ -441,8 +441,11 @@ class WebServer:
|
||||
sources = query.get(k).split(",")
|
||||
alerts = [a for a in alerts if a.source and a.source in sources]
|
||||
case "sig":
|
||||
# If a list of sigs is provided, the alert must have a sig and it must match one of them.
|
||||
# The special "sig" "NO_SIG", when supplied in the list, mathches alerts with no sig.
|
||||
sigs = query.get(k).split(",")
|
||||
alerts = [a for a in alerts if a.sig and a.sig in sigs]
|
||||
include_no_sig = "NO_SIG" in sigs
|
||||
spots = [a for a in alerts if (a.sig and a.sig in sigs) or (include_no_sig and not a.sig)]
|
||||
case "dx_continent":
|
||||
dxconts = query.get(k).split(",")
|
||||
alerts = [a for a in alerts if a.dx_continent and a.dx_continent in dxconts]
|
||||
|
||||
@@ -72,7 +72,7 @@ class DXCluster(SpotProvider):
|
||||
de_call=match.group(1),
|
||||
freq=float(match.group(2)) * 1000,
|
||||
comment=match.group(4).strip(),
|
||||
icon="desktop",
|
||||
icon="tower-cell",
|
||||
time=spot_datetime.timestamp())
|
||||
|
||||
# Add to our list
|
||||
|
||||
@@ -41,42 +41,47 @@ class GMA(HTTPSpotProvider):
|
||||
dx_longitude=float(source_spot["LON"]) if (source_spot["LON"] and source_spot["LON"] != "") else None)
|
||||
|
||||
# GMA doesn't give what programme (SIG) the reference is for until we separately look it up.
|
||||
ref_response = SEMI_STATIC_URL_DATA_CACHE.get(self.REF_INFO_URL_ROOT + source_spot["REF"],
|
||||
headers=HTTP_HEADERS)
|
||||
# Sometimes this is blank, so handle that
|
||||
if ref_response.text is not None and ref_response.text != "":
|
||||
ref_info = ref_response.json()
|
||||
# If this is POTA, SOTA or WWFF data we already have it through other means, so ignore. POTA and WWFF
|
||||
# spots come through with reftype=POTA or reftype=WWFF. SOTA is harder to figure out because both SOTA
|
||||
# and GMA summits come through with reftype=Summit, so we must check for the presence of a "sota" entry
|
||||
# to determine if it's a SOTA summit.
|
||||
if "reftype" in ref_info and ref_info["reftype"] not in ["POTA", "WWFF"] and (ref_info["reftype"] != "Summit" or ref_info["sota"] == ""):
|
||||
match ref_info["reftype"]:
|
||||
case "Summit":
|
||||
spot.sig_refs[0].sig = "GMA"
|
||||
spot.sig = "GMA"
|
||||
case "IOTA Island":
|
||||
spot.sig_refs[0].sig = "IOTA"
|
||||
spot.sig = "IOTA"
|
||||
case "Lighthouse (ILLW)":
|
||||
spot.sig_refs[0].sig = "ILLW"
|
||||
spot.sig = "ILLW"
|
||||
case "Lighthouse (ARLHS)":
|
||||
spot.sig_refs[0].sig = "ARLHS"
|
||||
spot.sig = "ARLHS"
|
||||
case "Castle":
|
||||
spot.sig_refs[0].sig = "WCA"
|
||||
spot.sig = "WCA"
|
||||
case "Mill":
|
||||
spot.sig_refs[0].sig = "MOTA"
|
||||
spot.sig = "MOTA"
|
||||
case _:
|
||||
logging.warn("GMA spot found with ref type " + ref_info[
|
||||
"reftype"] + ", developer needs to add support for this!")
|
||||
spot.sig_refs[0].sig = ref_info["reftype"]
|
||||
spot.sig = ref_info["reftype"]
|
||||
if "REF" in source_spot:
|
||||
try:
|
||||
ref_response = SEMI_STATIC_URL_DATA_CACHE.get(self.REF_INFO_URL_ROOT + source_spot["REF"],
|
||||
headers=HTTP_HEADERS)
|
||||
# Sometimes this is blank, so handle that
|
||||
if ref_response.text is not None and ref_response.text != "":
|
||||
ref_info = ref_response.json()
|
||||
# If this is POTA, SOTA or WWFF data we already have it through other means, so ignore. POTA and WWFF
|
||||
# spots come through with reftype=POTA or reftype=WWFF. SOTA is harder to figure out because both SOTA
|
||||
# and GMA summits come through with reftype=Summit, so we must check for the presence of a "sota" entry
|
||||
# to determine if it's a SOTA summit.
|
||||
if "reftype" in ref_info and ref_info["reftype"] not in ["POTA", "WWFF"] and (
|
||||
ref_info["reftype"] != "Summit" or ref_info["sota"] == ""):
|
||||
match ref_info["reftype"]:
|
||||
case "Summit":
|
||||
spot.sig_refs[0].sig = "GMA"
|
||||
spot.sig = "GMA"
|
||||
case "IOTA Island":
|
||||
spot.sig_refs[0].sig = "IOTA"
|
||||
spot.sig = "IOTA"
|
||||
case "Lighthouse (ILLW)":
|
||||
spot.sig_refs[0].sig = "ILLW"
|
||||
spot.sig = "ILLW"
|
||||
case "Lighthouse (ARLHS)":
|
||||
spot.sig_refs[0].sig = "ARLHS"
|
||||
spot.sig = "ARLHS"
|
||||
case "Castle":
|
||||
spot.sig_refs[0].sig = "WCA"
|
||||
spot.sig = "WCA"
|
||||
case "Mill":
|
||||
spot.sig_refs[0].sig = "MOTA"
|
||||
spot.sig = "MOTA"
|
||||
case _:
|
||||
logging.warn("GMA spot found with ref type " + ref_info[
|
||||
"reftype"] + ", developer needs to add support for this!")
|
||||
spot.sig_refs[0].sig = ref_info["reftype"]
|
||||
spot.sig = ref_info["reftype"]
|
||||
|
||||
# Add to our list. Don't worry about de-duping, removing old spots etc. at this point; other code will do
|
||||
# that for us.
|
||||
new_spots.append(spot)
|
||||
# Add to our list. Don't worry about de-duping, removing old spots etc. at this point; other code will do
|
||||
# that for us.
|
||||
new_spots.append(spot)
|
||||
except:
|
||||
logging.warn("Exception when looking up " + self.REF_INFO_URL_ROOT + source_spot["REF"] + ", ignoring this spot for now")
|
||||
return new_spots
|
||||
|
||||
@@ -34,8 +34,9 @@ class SpotProvider:
|
||||
if datetime.fromtimestamp(spot.time, pytz.UTC) > self.last_spot_time:
|
||||
# Fill in any blanks
|
||||
spot.infer_missing()
|
||||
# Add to the list
|
||||
self.spots.add(spot.id, spot, expire=MAX_SPOT_AGE)
|
||||
# Add to the list, provided it heas not already expired.
|
||||
if not spot.expired():
|
||||
self.spots.add(spot.id, spot, expire=MAX_SPOT_AGE)
|
||||
self.last_spot_time = datetime.fromtimestamp(max(map(lambda s: s.time, spots)), pytz.UTC)
|
||||
|
||||
# Submit a single spot retrieved from the provider. This will be added to the list regardless of its age. Spots
|
||||
@@ -44,9 +45,10 @@ class SpotProvider:
|
||||
def submit(self, spot):
|
||||
# Fill in any blanks
|
||||
spot.infer_missing()
|
||||
# Add to the list
|
||||
self.spots.add(spot.id, spot, expire=MAX_SPOT_AGE)
|
||||
self.last_spot_time = datetime.fromtimestamp(spot.time, pytz.UTC)
|
||||
# Add to the list, provided it heas not already expired.
|
||||
if not spot.expired():
|
||||
self.spots.add(spot.id, spot, expire=MAX_SPOT_AGE)
|
||||
self.last_spot_time = datetime.fromtimestamp(spot.time, pytz.UTC)
|
||||
|
||||
# Stop any threads and prepare for application shutdown
|
||||
def stop(self):
|
||||
|
||||
@@ -47,7 +47,8 @@ class WOTA(HTTPSpotProvider):
|
||||
freq_mode = desc_split[0].replace("Frequencies/modes:", "").strip()
|
||||
freq_mode_split = re.split(r'[\-\s]+', freq_mode)
|
||||
freq_hz = float(freq_mode_split[0]) * 1000000
|
||||
mode = freq_mode_split[1].upper()
|
||||
if len(freq_mode_split) > 1:
|
||||
mode = freq_mode_split[1].upper()
|
||||
|
||||
comment = None
|
||||
if len(desc_split) > 1:
|
||||
|
||||
43
spotproviders/xota.py
Normal file
43
spotproviders/xota.py
Normal file
@@ -0,0 +1,43 @@
|
||||
from datetime import datetime
|
||||
|
||||
from data.sig_ref import SIGRef
|
||||
from data.spot import Spot
|
||||
from spotproviders.http_spot_provider import HTTPSpotProvider
|
||||
|
||||
|
||||
# Spot provider for servers based on the "xOTA" software at https://github.com/nischu/xOTA/
|
||||
# The provider typically doesn't give us a lat/lon or SIG explicitly, so our own config provides this information. This
|
||||
# functionality is implemented for TOTA events.
|
||||
class XOTA(HTTPSpotProvider):
|
||||
POLL_INTERVAL_SEC = 300
|
||||
FIXED_LATITUDE = None
|
||||
FIXED_LONGITUDE = None
|
||||
SIG = None
|
||||
|
||||
def __init__(self, provider_config):
|
||||
super().__init__(provider_config, provider_config["url"] + "/api/spot/all", self.POLL_INTERVAL_SEC)
|
||||
self.FIXED_LATITUDE = provider_config["latitude"] if "latitude" in provider_config else None
|
||||
self.FIXED_LONGITUDE = provider_config["longitude"] if "longitude" in provider_config else None
|
||||
self.SIG = provider_config["sig"] if "sig" in provider_config else None
|
||||
|
||||
def http_response_to_spots(self, http_response):
|
||||
new_spots = []
|
||||
# Iterate through source data
|
||||
for source_spot in http_response.json():
|
||||
# Convert to our spot format
|
||||
spot = Spot(source=self.name,
|
||||
source_id=source_spot["id"],
|
||||
dx_call=source_spot["stationCallSign"].upper(),
|
||||
freq=float(source_spot["freq"]) * 1000,
|
||||
mode=source_spot["mode"].upper(),
|
||||
sig=self.SIG,
|
||||
sig_refs=[SIGRef(id=source_spot["reference"]["title"], sig=self.SIG, url=source_spot["reference"]["website"])],
|
||||
time=datetime.fromisoformat(source_spot["modificationDate"]).timestamp(),
|
||||
dx_latitude=self.FIXED_LATITUDE,
|
||||
dx_longitude=self.FIXED_LONGITUDE,
|
||||
qrt=source_spot["state"] != "active")
|
||||
|
||||
# Add to our list. Don't worry about de-duping, removing old spots etc. at this point; other code will do
|
||||
# that for us.
|
||||
new_spots.append(spot)
|
||||
return new_spots
|
||||
@@ -3,11 +3,19 @@
|
||||
<div id="info-container" class="mt-4">
|
||||
<h2 class="mt-4 mb-4">About Spothole</h2>
|
||||
<p>Spothole is a utility to aggregate "spots" from amateur radio DX clusters and xOTA spotting sites, and provide an open JSON API as well as a website to browse the data.</p>
|
||||
<p>While there are several other web-based interfaces to DX clusters, and sites that aggregate spots from various outdoor activity programmes for amateur radio, Spothole differentiates itself by supporting a large number of data sources, and by being "API first" rather than just providing a web front-end. This allows other software to be built on top of it.</p>
|
||||
<p>While there are several other web-based interfaces to DX clusters, and sites that aggregate spots from various outdoor activity programmes for amateur radio, Spothole differentiates itself by supporting a larger number of data sources, and by being "API first" rather than just providing a web front-end. This allows other software to be built on top of it.</p>
|
||||
<p>The API is deliberately well-defined with an <a href="/apidocs/openapi.yml">OpenAPI specification</a> and <a href="/apidocs">API documentation</a>. The API delivers spots in a consistent format regardless of the data source, freeing developers from needing to know how each individual data source presents its data.</p>
|
||||
<p>Spothole itself is also open source, Public Domain licenced code that anyone can take and modify. <a href="https://git.ianrenton.com/ian/metaspot/">The source code is here</a>. If you want to run your own copy of Spothole, or start modifying it for your own purposes, the <a href="https://git.ianrenton.com/ian/spothole/src/branch/main/README.md">README file</a> contains a description of how the software works and how it's laid out, as well as instructions for configuring systemd, nginx and anything else you might need to run your own server.</p>
|
||||
<p>The software was written by <a href="https://ianrenton.com">Ian Renton, MØTRT</a> and other contributors. Full details are available in the README.</p>
|
||||
<p>Spothole itself is also open source, Public Domain licenced code that anyone can take and modify. <a href="https://git.ianrenton.com/ian/metaspot/">The source code is here</a>.</p>
|
||||
<p>The software was written by <a href="https://ianrenton.com">Ian Renton, MØTRT</a> and other contributors. Full details are available in the <a href="https://git.ianrenton.com/ian/spothole/src/branch/main/README.md">README file</a>.</p>
|
||||
<p>This server is running Spothole version {{software_version}}.</p>
|
||||
<h2 class="mt-4 mb-4">Using Spothole</h2>
|
||||
<p>There are a number of different ways to use Spothole, depending on what you want to do with it and your level of technical skill:</p>
|
||||
<ol><li>You can <b>use it on the web</b>, like you are (probably) doing right now. This is how most people use it, to look up spots and alerts, and make interesting QSOs.</li>
|
||||
<li>If you are using an Android or iOS device, you can <b>"install" it on your device</b>. Spothole is a Progressive Web App, meaning it's not delivered through app stores, but if you open the page on Chrome (Android) or Safari (iOS) there will be an option in the menu to install it. It will then appear in your main app menu.</li>
|
||||
<li>You can <b>embed the web interface in another website</b> to show its spots in a custom dashboard or the like. The usage is explained in more detail in the <a href="https://git.ianrenton.com/ian/spothole/src/branch/main/README.md">README file</a>.</li>
|
||||
<li>You can <b>write your own client using the Spothole API</b>, using the main Spothole instance to provide data, and do whatever you like with it. The README contains guidance on how to do this, and the full API docs are linked above. You can also find reference implementations in the form of Spothole's own web-based front end, plus my other two tools built on Spothole: <a href="https://fieldspotter.radio">Field Spotter</a> and the <a href="https://qsomap.m0trt.radio">QSO Map Tool</a>.</li>
|
||||
<li>If you want to <b>run your own version of Spothole</b> so you can customise the configuration, such as enabling sources that I disable on the main instance, you can do that too. The README contains not only advice on how to set up Spothole but how to get it auto-starting with systemd, using an nginx reverse proxy, and setting up HTTPS support with certbot.</li>
|
||||
<li>Finally, you can of course download the source code and <b>develop Spothole to meet your needs</b>. Whether you contribute your changes back to the main repository is up to you. As usual, the README file contains some advice on the structure of the repository, and how to get started writing your own spot provider.</li></ol>
|
||||
<h2 id="faq" class="mt-4">FAQ</h2>
|
||||
<h4 class="mt-4">"Spots"? "DX Clusters"? What does any of this mean?</h4>
|
||||
<p>This is a tool for amateur ("ham") radio users. Many amateur radio operators like to make contacts with others who are doing something more interesting than sitting in their home "shack", such as people in rarely-seen countries, remote islands, or on mountaintops. Such operators are often "spotted", i.e. when someone speaks to them, they will put the details such as their operating frequency into an online system, to let others know where to find them. A DX Cluster is one type of those systems. Most outdoor radio awards programmes, such as "Parks on the Air" (POTA) have their own websites for posting spots.</p>
|
||||
@@ -16,10 +24,18 @@
|
||||
<h4 class="mt-4">What are "DX", "DE" and modes?</h4>
|
||||
<p>In amateur radio terminology, the "DX" contact is the "interesting" one that is using the frequency shown and looking for callers. They might be on a remote island or just in a local park, but either way it's interesting enough that someone has "spotted" them. The callsign listed under "DE" is the person who entered the spot of the "DX" operator. "Modes" are the type of communication they are using. For example you might see "CW" which is Morse Code, or voice "modes" like SSB or FM, or more exotic "data" modes which are used for computer-to-computer communication.</p>
|
||||
<h4 class="mt-4">What data sources are supported?</h4>
|
||||
<p>Spothole can retrieve spots from: Telnet-based DX clusters, the Reverse Beacon Network (RBN), the APRS Internet Service (APRS-IS), POTA, SOTA, WWFF, GMA, WWBOTA, HEMA, Parks 'n' Peaks, ZLOTA, WOTA, and the UK Packet Repeater Network.</p>
|
||||
<p>Spothole can retrieve alerts from: NG3K, POTA, SOTA, WWFF, Parks 'n' Peaks, WOTA and BOTA.</p>
|
||||
<p>Spothole can retrieve spots from: <a href="https://www.dxcluster.info/telnet/">Telnet-based DX clusters</a>, the <a href="https://www.reversebeacon.net/">Reverse Beacon Network (RBN)</a>, the <a href="https://www.aprs-is.net/">APRS Internet Service (APRS-IS)</a>, <a href="https://pota.app">POTA</a>, <a href="https://www.sota.org.uk/">SOTA</a>, <a href="https://wwff.co/">WWFF</a>, <a href="https://www.cqgma.org/">GMA</a>, <a href="https://wwbota.net/">WWBOTA</a>, <a href="http://www.hema.org.uk/">HEMA</a>, <a href="https://www.parksnpeaks.org/">Parks 'n' Peaks</a>, <a href="https://ontheair.nz">ZLOTA</a>, <a href="https://www.wota.org.uk/">WOTA</a>, the <a href="https://ukpacketradio.network/">UK Packet Repeater Network</a>, and any site based on the <a href="https://github.com/nischu/xOTA">xOTA software by nischu</a>.</p>
|
||||
<p>Spothole can retrieve alerts from: <a href="https://www.ng3k.com/">NG3K</a>, <a href="https://pota.app">POTA</a>, <a href="https://www.sota.org.uk/">SOTA</a>, <a href="https://wwff.co/">WWFF</a>, <a href="https://www.parksnpeaks.org/">Parks 'n' Peaks</a>, <a href="https://www.wota.org.uk/">WOTA</a> and <a href="https://www.beachesontheair.com/">BOTA</a>.</p>
|
||||
<p>Note that the server owner has not necessarily enabled all these data sources. In particular it is common to disable RBN, to avoid the server being swamped with FT8 traffic, and to disable APRS-IS and UK Packet Net so that the server only displays stations where there is likely to be an operator physically present for a QSO.</p>
|
||||
<p>Between the various data sources, the following Special Interest Groups (SIGs) are supported: POTA, SOTA, WWFF, GMA, WWBOTA, HEMA, IOTA, MOTA, ARLHS, ILLW, SIOTA, WCA, ZLOTA, KRMNPA, WOTA, BOTA, WAB & WAI.</p>
|
||||
<p>Between the various data sources, the following Special Interest Groups (SIGs) are supported: Parks on the Air (POTA), Summits on the Air (SOTA), Worldwide Flora & Fauna (WWFF), Global Mountain Activity (GMA), Worldwide Bunkers on the Air (WWBOTA), HuMPs Excluding Marilyns Award (HEMA), Islands on the Air (IOTA), Mills on the Air (MOTA), the Amateur Radio Lighthouse Socirty (ARLHS), International Lighthouse Lightship Weekend (ILLW), Silos on the Air (SIOTA), World Castles Award (WCA), New Zealand on the Air (ZLOTA), Keith Roget Memorial National Parks Award (KRMNPA), Wainwrights on the Air (WOTA), Beaches on the Air (BOTA), Worked All Britain (WAB), Worked All Ireland (WAI), and Toilets on the Air (TOTA).</p>
|
||||
<p>As of the time of writing in November 2025, I think Spothole captures essentially all outdoor radio programmes that have a defined reference list, and almost certainly those that have a spotting/alerting API. If you know of one I've missed, please let me know!</p>
|
||||
<h4 class="mt-4">Why can I filter spots by both SIG and Source? Isn't that basically the same thing?</h4>
|
||||
<p>Mostly, but not quite. While POTA spots generally come from the POTA source and so on, there are a few exceptions:</p>
|
||||
<ol><li>Sources like GMA and Parks 'n' Peaks provide spots for multiple different programmes (SIGs).</li>
|
||||
<li>Cluster spots may name SIGs in their comment, in which case the source remains the Cluster, but a SIG is assigned.</li>
|
||||
<li>Some SIGs, such as Worked all Britain (WAB), don't have their own spotting site and can <em>only</em> be identified through comments on spots retrieved from other sources.</li>
|
||||
<li>SIGs have well-defined names, whereas the server owner may name the sources as they see fit.</li></ol>
|
||||
<p>Spothole's web interface exists not just for the end user, but also as a reference implementation for the API, so I have chosen to demonstrate both methods of filtering.</p>
|
||||
<h4 class="mt-4">How is this better than DXheat, DXsummit, POTA's own website, etc?</h4>
|
||||
<p>It's probably not? But it's nice to have choice.</p>
|
||||
<p>I think it's got three key advantages over those sites:</p>
|
||||
@@ -46,4 +62,5 @@
|
||||
<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=1"></script>
|
||||
<script>$(document).ready(function() { $("#nav-link-about").addClass("active"); }); <!-- highlight active page in nav --></script>
|
||||
@@ -68,6 +68,6 @@
|
||||
|
||||
</div>
|
||||
|
||||
<script src="/js/common.js"></script>
|
||||
<script src="/js/add-spot.js"></script>
|
||||
<script src="/js/common.js?v=1"></script>
|
||||
<script src="/js/add-spot.js?v=1"></script>
|
||||
<script>$(document).ready(function() { $("#nav-link-add-spot").addClass("active"); }); <!-- highlight active page in nav --></script>
|
||||
@@ -1,7 +1,7 @@
|
||||
% rebase('webpage_base.tpl')
|
||||
|
||||
<div class="mt-3">
|
||||
<div class="row">
|
||||
<div id="settingsButtonRow" class="row">
|
||||
<div class="col-auto me-auto pt-3">
|
||||
<p id="timing-container">Loading...</p>
|
||||
</div>
|
||||
@@ -107,6 +107,19 @@
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="col">
|
||||
<div class="card">
|
||||
<div class="card-body">
|
||||
<h5 class="card-title">Theme</h5>
|
||||
<div class="form-group">
|
||||
<div class="form-check form-check-inline">
|
||||
<input class="form-check-input storeable-checkbox" type="checkbox" id="darkMode" value="darkMode" oninput="toggleDarkMode();">
|
||||
<label class="form-check-label" for="darkMode">Dark mode</label>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="col">
|
||||
<div class="card">
|
||||
<div class="card-body">
|
||||
@@ -152,6 +165,6 @@
|
||||
|
||||
</div>
|
||||
|
||||
<script src="/js/common.js"></script>
|
||||
<script src="/js/alerts.js"></script>
|
||||
<script src="/js/common.js?v=1"></script>
|
||||
<script src="/js/alerts.js?v=1"></script>
|
||||
<script>$(document).ready(function() { $("#nav-link-alerts").addClass("active"); }); <!-- highlight active page in nav --></script>
|
||||
@@ -1,7 +1,7 @@
|
||||
% rebase('webpage_base.tpl')
|
||||
|
||||
<div class="mt-3">
|
||||
<div class="row">
|
||||
<div id="settingsButtonRow" class="row">
|
||||
<div class="col-auto me-auto pt-3">
|
||||
<p id="timing-container">Loading...</p>
|
||||
</div>
|
||||
@@ -26,7 +26,7 @@
|
||||
|
||||
</div>
|
||||
<div class="card-body">
|
||||
<div class="row row-cols-1 g-4 mb-4">
|
||||
<div class="row row-cols-1 g-4 mb-4 row-cols-md-3">
|
||||
<div class="col">
|
||||
<div class="card">
|
||||
<div class="card-body">
|
||||
@@ -35,8 +35,24 @@
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="col">
|
||||
<div class="card">
|
||||
<div class="card-body">
|
||||
<h5 class="card-title">SIGs</h5>
|
||||
<p id="sig-options" class="card-text spothole-card-text"></p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="col">
|
||||
<div class="card">
|
||||
<div class="card-body">
|
||||
<h5 class="card-title">Sources</h5>
|
||||
<p id="source-options" class="card-text spothole-card-text"></p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="row row-cols-1 row-cols-md-4 g-4">
|
||||
<div class="row row-cols-1 row-cols-md-3 g-4">
|
||||
<div class="col">
|
||||
<div class="card">
|
||||
<div class="card-body">
|
||||
@@ -61,14 +77,6 @@
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="col">
|
||||
<div class="card">
|
||||
<div class="card-body">
|
||||
<h5 class="card-title">Sources</h5>
|
||||
<p id="source-options" class="card-text spothole-card-text"></p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -99,6 +107,19 @@
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="col">
|
||||
<div class="card">
|
||||
<div class="card-body">
|
||||
<h5 class="card-title">Theme</h5>
|
||||
<div class="form-group">
|
||||
<div class="form-check form-check-inline">
|
||||
<input class="form-check-input storeable-checkbox" type="checkbox" id="darkMode" value="darkMode" oninput="toggleDarkMode();">
|
||||
<label class="form-check-label" for="darkMode">Dark mode</label>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -107,7 +128,7 @@
|
||||
|
||||
</div>
|
||||
|
||||
<script src="/js/common.js"></script>
|
||||
<script src="/js/spotandmap.js"></script>
|
||||
<script src="/js/bands.js"></script>
|
||||
<script src="/js/common.js?v=1"></script>
|
||||
<script src="/js/spotsbandsandmap.js?v=1"></script>
|
||||
<script src="/js/bands.js?v=1"></script>
|
||||
<script>$(document).ready(function() { $("#nav-link-bands").addClass("active"); }); <!-- highlight active page in nav --></script>
|
||||
@@ -3,7 +3,7 @@
|
||||
<head>
|
||||
<meta charset="utf-8"/>
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0, viewport-fit=cover">
|
||||
<meta name="color-scheme" content="light">
|
||||
<meta name="color-scheme" content="light dark">
|
||||
<meta name="theme-color" content="white"/>
|
||||
<meta name="mobile-web-app-capable" content="yes">
|
||||
<meta name="apple-mobile-web-app-capable" content="yes">
|
||||
@@ -48,15 +48,15 @@
|
||||
</head>
|
||||
<body>
|
||||
<div class="container">
|
||||
<nav class="navbar navbar-expand-lg bg-body p-0 border-bottom">
|
||||
<nav id="header" class="navbar navbar-expand-lg bg-body p-0 border-bottom">
|
||||
<div class="container-fluid p-0">
|
||||
<a class="navbar-brand" href="/">
|
||||
<img src="/img/logo.png" width="192" height="60" alt="Spothole">
|
||||
<img src="/img/logo.png" class="logo" width="192" height="60" alt="Spothole">
|
||||
</a>
|
||||
<button class="navbar-toggler" type="button" data-bs-toggle="collapse" data-bs-target="#navbarTogglerDemo02" aria-controls="navbarTogglerDemo02" aria-expanded="false" aria-label="Toggle navigation">
|
||||
<button class="navbar-toggler" type="button" data-bs-toggle="collapse" data-bs-target="#navbar-toggler-content" aria-controls="navbar-toggler-content" aria-expanded="false" aria-label="Toggle navigation">
|
||||
<span class="navbar-toggler-icon"></span>
|
||||
</button>
|
||||
<div class="collapse navbar-collapse" id="navbarTogglerDemo02">
|
||||
<div class="collapse navbar-collapse" id="navbar-toggler-content">
|
||||
<ul class="navbar-nav me-auto mb-2 mb-lg-0">
|
||||
<li class="nav-item ms-4"><a href="/" class="nav-link" id="nav-link-spots"><i class="fa-solid fa-tower-cell"></i> Spots</a></li>
|
||||
<li class="nav-item ms-4"><a href="/map" class="nav-link" id="nav-link-map"><i class="fa-solid fa-map"></i> Map</a></li>
|
||||
@@ -79,7 +79,7 @@
|
||||
|
||||
</main>
|
||||
|
||||
<div class="hideonmobile hideonmap">
|
||||
<div id="footer" class="hideonmobile hideonmap">
|
||||
<footer class="d-flex flex-wrap justify-content-between align-items-center py-3 my-4 border-top">
|
||||
<p class="col-md-4 mb-0 text-body-secondary">Made with love by <a href="https://ianrenton.com" class="text-body-secondary">Ian, MØTRT</a> and other contributors.</p>
|
||||
<p class="col-md-4 mb-0 justify-content-center text-body-secondary" style="text-align: center;">Spothole v{{software_version}}</p>
|
||||
@@ -101,5 +101,7 @@
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div id="embeddedModeFooter" class="text-body-secondary pt-2 px-3 pb-1">Powered by <img src="/img/logo.png" class="logo" width="96" height="30" alt="Spothole"></div>
|
||||
|
||||
</body>
|
||||
</html>
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
% rebase('webpage_base.tpl')
|
||||
|
||||
<div id="map">
|
||||
<div id="maptools" class="mt-3 px-3" style="z-index: 1002; position: relative;">
|
||||
<div id="settingsButtonRowMap" class="mt-3 px-3" style="z-index: 1002; position: relative;">
|
||||
<div class="row">
|
||||
<div class="col-auto me-auto pt-3"></div>
|
||||
<div class="col-auto">
|
||||
@@ -25,7 +25,7 @@
|
||||
|
||||
</div>
|
||||
<div class="card-body">
|
||||
<div class="row row-cols-1 g-4 mb-4">
|
||||
<div class="row row-cols-1 g-4 mb-4 row-cols-md-3">
|
||||
<div class="col">
|
||||
<div class="card">
|
||||
<div class="card-body">
|
||||
@@ -34,8 +34,24 @@
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="col">
|
||||
<div class="card">
|
||||
<div class="card-body">
|
||||
<h5 class="card-title">SIGs</h5>
|
||||
<p id="sig-options" class="card-text spothole-card-text"></p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="col">
|
||||
<div class="card">
|
||||
<div class="card-body">
|
||||
<h5 class="card-title">Sources</h5>
|
||||
<p id="source-options" class="card-text spothole-card-text"></p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="row row-cols-1 row-cols-md-4 g-4">
|
||||
<div class="row row-cols-1 row-cols-md-3 g-4">
|
||||
<div class="col">
|
||||
<div class="card">
|
||||
<div class="card-body">
|
||||
@@ -60,14 +76,6 @@
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="col">
|
||||
<div class="card">
|
||||
<div class="card-body">
|
||||
<h5 class="card-title">Sources</h5>
|
||||
<p id="source-options" class="card-text spothole-card-text"></p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -111,6 +119,19 @@
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="col">
|
||||
<div class="card">
|
||||
<div class="card-body">
|
||||
<h5 class="card-title">Theme</h5>
|
||||
<div class="form-group">
|
||||
<div class="form-check form-check-inline">
|
||||
<input class="form-check-input storeable-checkbox" type="checkbox" id="darkMode" value="darkMode" oninput="toggleDarkMode();">
|
||||
<label class="form-check-label" for="darkMode">Dark mode</label>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -125,7 +146,7 @@
|
||||
<script src="https://cdn.jsdelivr.net/npm/leaflet.geodesic"></script>
|
||||
<script src="https://cdn.jsdelivr.net/npm/@joergdietrich/leaflet.terminator@1.1.0/L.Terminator.min.js"></script>
|
||||
|
||||
<script src="/js/common.js"></script>
|
||||
<script src="/js/spotandmap.js"></script>
|
||||
<script src="/js/map.js"></script>
|
||||
<script src="/js/common.js?v=1"></script>
|
||||
<script src="/js/spotsbandsandmap.js?v=1"></script>
|
||||
<script src="/js/map.js?v=1"></script>
|
||||
<script>$(document).ready(function() { $("#nav-link-map").addClass("active"); }); <!-- highlight active page in nav --></script>
|
||||
@@ -8,7 +8,7 @@
|
||||
</div>
|
||||
|
||||
<div class="mt-3">
|
||||
<div class="row">
|
||||
<div id="settingsButtonRow" class="row">
|
||||
<div class="col-auto me-auto pt-3">
|
||||
<p id="timing-container">Loading...</p>
|
||||
</div>
|
||||
@@ -37,7 +37,7 @@
|
||||
|
||||
</div>
|
||||
<div class="card-body">
|
||||
<div class="row row-cols-1 g-4 mb-4">
|
||||
<div class="row row-cols-1 g-4 mb-4 row-cols-md-3">
|
||||
<div class="col">
|
||||
<div class="card">
|
||||
<div class="card-body">
|
||||
@@ -46,8 +46,24 @@
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="col">
|
||||
<div class="card">
|
||||
<div class="card-body">
|
||||
<h5 class="card-title">SIGs</h5>
|
||||
<p id="sig-options" class="card-text spothole-card-text"></p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="col">
|
||||
<div class="card">
|
||||
<div class="card-body">
|
||||
<h5 class="card-title">Sources</h5>
|
||||
<p id="source-options" class="card-text spothole-card-text"></p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="row row-cols-1 row-cols-md-4 g-4">
|
||||
<div class="row row-cols-1 row-cols-md-3 g-4">
|
||||
<div class="col">
|
||||
<div class="card">
|
||||
<div class="card-body">
|
||||
@@ -72,14 +88,6 @@
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="col">
|
||||
<div class="card">
|
||||
<div class="card-body">
|
||||
<h5 class="card-title">Sources</h5>
|
||||
<p id="source-options" class="card-text spothole-card-text"></p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -123,6 +131,30 @@
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="col">
|
||||
<div class="card">
|
||||
<div class="card-body">
|
||||
<h5 class="card-title">Location</h5>
|
||||
<div class="form-group spothole-card-text">
|
||||
<label for="userGrid">Your grid:</label>
|
||||
<input type="text" class="storeable-text form-control" id="userGrid" placeholder="AA00aa" oninput="userGridUpdated();" style="width: 10em; display: inline-block;">
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="col">
|
||||
<div class="card">
|
||||
<div class="card-body">
|
||||
<h5 class="card-title">Theme</h5>
|
||||
<div class="form-group">
|
||||
<div class="form-check form-check-inline">
|
||||
<input class="form-check-input storeable-checkbox" type="checkbox" id="darkMode" value="darkMode" oninput="toggleDarkMode();">
|
||||
<label class="form-check-label" for="darkMode">Dark mode</label>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="col">
|
||||
<div class="card">
|
||||
<div class="card-body">
|
||||
@@ -168,17 +200,6 @@
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="col">
|
||||
<div class="card">
|
||||
<div class="card-body">
|
||||
<h5 class="card-title">Location</h5>
|
||||
<div class="form-group spothole-card-text">
|
||||
<label for="userGrid">Your grid:</label>
|
||||
<input type="text" class="storeable-text form-control" id="userGrid" placeholder="AA00aa" oninput="userGridUpdated();" style="width: 10em; display: inline-block;">
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -187,7 +208,7 @@
|
||||
|
||||
</div>
|
||||
|
||||
<script src="/js/common.js"></script>
|
||||
<script src="/js/spotandmap.js"></script>
|
||||
<script src="/js/spots.js"></script>
|
||||
<script src="/js/common.js?v=1"></script>
|
||||
<script src="/js/spotsbandsandmap.js?v=1"></script>
|
||||
<script src="/js/spots.js?v=1"></script>
|
||||
<script>$(document).ready(function() { $("#nav-link-spots").addClass("active"); }); <!-- highlight active page in nav --></script>
|
||||
@@ -2,6 +2,6 @@
|
||||
|
||||
<div id="status-container" class="row row-cols-1 row-cols-md-4 g-4 mt-4"></div>
|
||||
|
||||
<script src="/js/common.js"></script>
|
||||
<script src="/js/status.js"></script>
|
||||
<script src="/js/common.js?v=1"></script>
|
||||
<script src="/js/status.js?v=1"></script>
|
||||
<script>$(document).ready(function() { $("#nav-link-status").addClass("active"); }); <!-- highlight active page in nav --></script>
|
||||
@@ -58,13 +58,13 @@ paths:
|
||||
$ref: "#/components/schemas/Source"
|
||||
- name: sig
|
||||
in: query
|
||||
description: "Limit the spots to only ones from one or more Special Interest Groups provided as an argument. To select more than one SIG, supply a comma-separated list."
|
||||
description: "Limit the spots to only ones from one or more Special Interest Groups provided as an argument. To select more than one SIG, supply a comma-separated list. The special `sig` name `NO_SIG` matches spots with no sig set. You can use `sig=NO_SIG` to specifically only return generic spots with no associated SIG. You can also use combinations to request for example POTA + no SIG, but reject other SIGs. If you want to request 'every SIG and not No SIG', see the `needs_sig` query parameter for a shortcut."
|
||||
required: false
|
||||
schema:
|
||||
$ref: "#/components/schemas/SIGName"
|
||||
$ref: "#/components/schemas/SIGNameIncludingNoSIG"
|
||||
- name: needs_sig
|
||||
in: query
|
||||
description: "Limit the spots to only ones with a Special Interest Group such as POTA. Because supplying all known SIGs as a `sigs` parameter is unwieldy, and leaving `sigs` blank will also return spots with *no* SIG, this parameter can be set true to return only spots with a SIG, regardless of what it is, so long as it's not blank. This is what Field Spotter uses to exclude generic cluster spots and only retrieve xOTA things."
|
||||
description: "Limit the spots to only ones with a Special Interest Group such as POTA. Because supplying all known SIGs as a `sigs` parameter is unwieldy, and leaving `sigs` blank will also return spots with *no* SIG, this parameter can be set true to return only spots with a SIG, regardless of what it is, so long as it's not blank. This is the equivalent of supplying the `sig` query param with a list of every known SIG apart from the special `NO_SIG` value. This is what Field Spotter uses to exclude generic cluster spots and only retrieve xOTA things."
|
||||
required: false
|
||||
schema:
|
||||
type: boolean
|
||||
@@ -190,10 +190,10 @@ paths:
|
||||
$ref: "#/components/schemas/Source"
|
||||
- name: sig
|
||||
in: query
|
||||
description: "Limit the alerts to only ones from one or more Special Interest Groups. To select more than one SIG, supply a comma-separated list."
|
||||
description: "Limit the alerts to only ones from one or more Special Interest Groups. To select more than one SIG, supply a comma-separated list. The special value 'NO_SIG' can be included to return alerts specifically without an associated SIG (i.e. general DXpeditions)."
|
||||
required: false
|
||||
schema:
|
||||
$ref: "#/components/schemas/SIGName"
|
||||
$ref: "#/components/schemas/SIGNameIncludingNoSIG"
|
||||
- name: dx_continent
|
||||
in: query
|
||||
description: "Limit the alerts to only ones where the DX operator is on the given continent(s). To select more than one continent, supply a comma-separated list."
|
||||
@@ -588,6 +588,32 @@ components:
|
||||
- BOTA
|
||||
- WAB
|
||||
- WAI
|
||||
- TOTA
|
||||
example: POTA
|
||||
|
||||
SIGNameIncludingNoSIG:
|
||||
type: string
|
||||
enum:
|
||||
- POTA
|
||||
- SOTA
|
||||
- WWFF
|
||||
- WWBOTA
|
||||
- GMA
|
||||
- HEMA
|
||||
- WCA
|
||||
- MOTA
|
||||
- SIOTA
|
||||
- ARLHS
|
||||
- ILLW
|
||||
- ZLOTA
|
||||
- KRMNPA
|
||||
- IOTA
|
||||
- WOTA
|
||||
- BOTA
|
||||
- WAB
|
||||
- WAI
|
||||
- TOTA
|
||||
- NO_SIG
|
||||
example: POTA
|
||||
|
||||
Continent:
|
||||
|
||||
@@ -4,6 +4,38 @@
|
||||
font-weight: bold;
|
||||
}
|
||||
|
||||
/* In embedded mode, hide header/footer/settings. "#header div" is kind of janky but for some reason if we hide the
|
||||
whole of #header, the map vertical sizing breaks. */
|
||||
[embedded-mode=true] #header div, [embedded-mode=true] #footer,
|
||||
[embedded-mode=true] #settingsButtonRow, [embedded-mode=true] #settingsButtonRowMap {
|
||||
display: none;
|
||||
}
|
||||
|
||||
/* Display floating footer in embedded mode only */
|
||||
#embeddedModeFooter {
|
||||
display: none;
|
||||
position: fixed;
|
||||
bottom: 0;
|
||||
right: 0;
|
||||
background: var(--bs-body-bg);
|
||||
border-radius: 1em 0 0 0;
|
||||
font-size: 0.9em;
|
||||
border-top: 1px solid grey;
|
||||
border-left: 1px solid grey;
|
||||
}
|
||||
[embedded-mode=true] #embeddedModeFooter {
|
||||
display: block;
|
||||
}
|
||||
#embeddedModeFooter img.logo {
|
||||
position: relative;
|
||||
top: -2px;
|
||||
}
|
||||
|
||||
/* Invert logo colours in dark mode */
|
||||
[data-bs-theme=dark] .logo {
|
||||
filter: invert(100%) hue-rotate(180deg) brightness(80%);
|
||||
}
|
||||
|
||||
|
||||
/* INTRO/WARNING BOXES */
|
||||
|
||||
@@ -26,8 +58,13 @@ div.container {
|
||||
min-height:100svh;
|
||||
}
|
||||
|
||||
[embedded-mode=true] div.container {
|
||||
width: 100% !important;
|
||||
max-width: 100% !important;
|
||||
}
|
||||
|
||||
/* ABOUT PAGE*/
|
||||
|
||||
/* ABOUT PAGE */
|
||||
|
||||
#info-container{
|
||||
width: 100%;
|
||||
@@ -137,6 +174,18 @@ tr.table-faded td span {
|
||||
text-decoration: line-through !important;
|
||||
}
|
||||
|
||||
/* Fudge apply our own "dark primary" and "dark danger" backgrounds as Bootstrap doesn't do this itself */
|
||||
[data-bs-theme=dark] tr.table-primary {
|
||||
--bs-table-bg: #053680;
|
||||
--bs-table-border-color: #021b42;
|
||||
--bs-table-color: white;
|
||||
}
|
||||
[data-bs-theme=dark] tr.table-danger {
|
||||
--bs-table-bg: #74272e;
|
||||
--bs-table-border-color: #530208;
|
||||
--bs-table-color: white;
|
||||
}
|
||||
|
||||
|
||||
/* MAP */
|
||||
div#map {
|
||||
@@ -152,6 +201,12 @@ div#map {
|
||||
font-family: var(--bs-body-font-family) !important;
|
||||
}
|
||||
|
||||
[data-bs-theme=dark] .leaflet-layer,
|
||||
[data-bs-theme=dark] .leaflet-control-attribution {
|
||||
filter: invert(100%) hue-rotate(180deg) brightness(95%) contrast(90%);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/* BANDS PANEL */
|
||||
|
||||
@@ -227,6 +282,10 @@ div.band-spot {
|
||||
cursor: default;
|
||||
}
|
||||
|
||||
[data-bs-theme=dark] div.band-spot {
|
||||
background-color: black;
|
||||
}
|
||||
|
||||
div.band-spot:hover {
|
||||
z-index: 999;
|
||||
}
|
||||
@@ -261,10 +320,10 @@ div.band-spot:hover span.band-spot-info {
|
||||
margin-right: -1em;
|
||||
}
|
||||
/* Avoid map page filters panel being larger than the map itself */
|
||||
#maptools .appearing-panel {
|
||||
#settingsButtonRowMap .appearing-panel {
|
||||
max-height: 30em;
|
||||
}
|
||||
#maptools .appearing-panel .card-body {
|
||||
#settingsButtonRowMap .appearing-panel .card-body {
|
||||
max-height: 26em;
|
||||
overflow: scroll;
|
||||
}
|
||||
|
||||
@@ -51,7 +51,7 @@ function updateTable() {
|
||||
var showRef = $("#tableShowRef")[0].checked;
|
||||
|
||||
// Populate table with headers
|
||||
let table = $('<table class="table table-hover">').append('<thead><tr class="table-primary"></tr></thead><tbody></tbody>');
|
||||
let table = $('<table class="table">').append('<thead><tr class="table-primary"></tr></thead><tbody></tbody>');
|
||||
if (showStartTime) {
|
||||
table.find('thead tr').append(`<th>${useLocalTime ? "Start (Local)" : "Start UTC"}</th>`);
|
||||
}
|
||||
@@ -292,6 +292,11 @@ function loadOptions() {
|
||||
})));
|
||||
$("#alerts-to-fetch").val(options["web-ui-options"]["alert-count-default"]);
|
||||
|
||||
// Load URL params. These may select things from the various filter & display options, so the function needs
|
||||
// to be called after these are set up, but if the URL params ask for "embedded mode", this will suppress
|
||||
// loading settings, so this needs to be called before that.
|
||||
loadURLParams();
|
||||
|
||||
// Load filters from settings storage
|
||||
loadSettings();
|
||||
|
||||
@@ -307,6 +312,12 @@ function filtersUpdated() {
|
||||
saveSettings();
|
||||
}
|
||||
|
||||
// Function to set dark mode based on the state of the UI toggle in spots, bands and map pages
|
||||
function toggleDarkMode() {
|
||||
enableDarkMode($("#darkMode")[0].checked);
|
||||
saveSettings();
|
||||
}
|
||||
|
||||
// React to toggling/closing panels
|
||||
function toggleFiltersPanel() {
|
||||
// If we are going to display the filters panel, hide the display panel
|
||||
|
||||
@@ -23,7 +23,7 @@ function loadSpots() {
|
||||
// Build a query string for the API, based on the filters that the user has selected.
|
||||
function buildQueryString() {
|
||||
var str = "?";
|
||||
["dx_continent", "de_continent", "mode_type", "source", "band"].forEach(fn => {
|
||||
["dx_continent", "de_continent", "mode_type", "source", "band", "sig"].forEach(fn => {
|
||||
if (!allFilterOptionsSelected(fn)) {
|
||||
str = str + getQueryStringFor(fn) + "&";
|
||||
}
|
||||
@@ -230,6 +230,7 @@ function loadOptions() {
|
||||
|
||||
// Populate the filters panel
|
||||
generateBandsMultiToggleFilterCard(options["bands"]);
|
||||
generateSIGsMultiToggleFilterCard(options["sigs"]);
|
||||
generateMultiToggleFilterCard("#dx-continent-options", "dx_continent", options["continents"]);
|
||||
generateMultiToggleFilterCard("#de-continent-options", "de_continent", options["continents"]);
|
||||
generateMultiToggleFilterCard("#mode-options", "mode_type", options["mode_types"]);
|
||||
@@ -242,6 +243,11 @@ function loadOptions() {
|
||||
})));
|
||||
$("#max-spot-age").val(options["web-ui-options"]["max-spot-age-default"] * 60);
|
||||
|
||||
// Load URL params. These may select things from the various filter & display options, so the function needs
|
||||
// to be called after these are set up, but if the URL params ask for "embedded mode", this will suppress
|
||||
// loading settings, so this needs to be called before that.
|
||||
loadURLParams();
|
||||
|
||||
// Load settings from settings storage now all the controls are available
|
||||
loadSettings();
|
||||
|
||||
|
||||
@@ -2,7 +2,74 @@
|
||||
var options = {};
|
||||
// Last time we updated the spots/alerts list on display.
|
||||
var lastUpdateTime;
|
||||
// Whether "embedded mode" is being used. This removes headers and footers, maximises the remaining content, and
|
||||
// uses URL params to configure the interface options rather than using the user's localstorage.
|
||||
var embeddedMode = false;
|
||||
|
||||
// Load and apply any URL params. This is used for "embedded mode" where another site can embed a version of
|
||||
// Spothole and provide its own interface options rather than using the user's saved ones. These may select things
|
||||
// from the various filter & display options, so this function needs to be called after these are set up, but if
|
||||
// the URL params ask for "embedded mode", this will suppress loading settings, so this needs to be called before
|
||||
// that occurs..
|
||||
function loadURLParams() {
|
||||
let params = new URLSearchParams(document.location.search);
|
||||
|
||||
// Handle embedded mode. We set a global to e.g. suppress loading/saving settings, and apply an attribute to the
|
||||
// top-level html element to use CSS selectors to remove bits of UI.
|
||||
let embedded = params.get("embedded");
|
||||
if (embedded != null && embedded === "true") {
|
||||
embeddedMode = true;
|
||||
$("html").attr("embedded-mode", "true");
|
||||
}
|
||||
|
||||
// Handle other params
|
||||
updateCheckboxFromParam(params, "dark-mode", "darkMode");
|
||||
updateSelectFromParam(params, "time-zone", "timeZone"); // Only on Spots and Alerts pages
|
||||
updateSelectFromParam(params, "limit", "spots-to-fetch"); // Only on Spots page
|
||||
updateSelectFromParam(params, "limit", "alerts-to-fetch"); // Only on Alerts page
|
||||
updateSelectFromParam(params, "max_age", "max-spot-age"); // Only on Map & Bands pages
|
||||
updateFilterFromParam(params, "band", "band");
|
||||
updateFilterFromParam(params, "sig", "sig");
|
||||
updateFilterFromParam(params, "source", "source");
|
||||
updateFilterFromParam(params, "mode_type", "mode_type");
|
||||
updateFilterFromParam(params, "dx_continent", "dx_continent");
|
||||
updateFilterFromParam(params, "de_continent", "de_continent");
|
||||
}
|
||||
|
||||
// Update an HTML checkbox element so that its selected matches the given parameter (which must have a true or false value)
|
||||
function updateCheckboxFromParam(params, paramName, checkboxID) {
|
||||
let v = params.get(paramName);
|
||||
if (v != null) {
|
||||
$("#" + checkboxID).prop("checked", (v === "true") ? true : false);
|
||||
// Extra check if this is the "dark mode" toggle
|
||||
if (checkboxID == "darkMode") {
|
||||
enableDarkMode((v === "true") ? true : false);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Update an HTML select element so that its value matches the given parameter
|
||||
function updateSelectFromParam(params, paramName, selectID) {
|
||||
let v = params.get(paramName);
|
||||
if (v != null) {
|
||||
$("#" + selectID).prop("value", v);
|
||||
}
|
||||
}
|
||||
|
||||
// Update a set of HTML checkbox elements describing a filter of the given name, so that any items named in the
|
||||
// parameter (as a comma-separated list) will be enabled, and all others disabled. e.g. if paramName is
|
||||
// "filter-band" and the params contain "filter-band=20m,40m", and prefix is "band", then #filter-button-band-30m
|
||||
// would be disabled but #filter-button-band-20m and #filter-button-band-40m would be enabled.
|
||||
function updateFilterFromParam(params, paramName, filterName) {
|
||||
let v = params.get(paramName);
|
||||
if (v != null) {
|
||||
// First uncheck all options for the filter
|
||||
$(".filter-button-" + filterName).prop("checked", false);
|
||||
// Now find out which ones should be enabled
|
||||
let s = v.split(",");
|
||||
s.forEach(val => $("#filter-button-" + filterName + "-" + val).prop("checked", true));
|
||||
}
|
||||
}
|
||||
|
||||
// For a parameter, such as dx_continent, get the query string for the current filter options.
|
||||
function getQueryStringFor(parameter) {
|
||||
@@ -199,29 +266,61 @@ function latLonForGridSWCornerPlusSize(grid) {
|
||||
return [lat, lon, latCellSize, lonCellSize];
|
||||
}
|
||||
|
||||
// Save settings to local storage
|
||||
function saveSettings() {
|
||||
// Find all storeable UI elements, store a key of "element id:property name" mapped to the value of that
|
||||
// property. For a checkbox, that's the "checked" property.
|
||||
$(".storeable-checkbox").each(function() {
|
||||
localStorage.setItem("#" + $(this)[0].id + ":checked", JSON.stringify($(this)[0].checked));
|
||||
});
|
||||
$(".storeable-select").each(function() {
|
||||
localStorage.setItem("#" + $(this)[0].id + ":value", JSON.stringify($(this)[0].value));
|
||||
});
|
||||
$(".storeable-text").each(function() {
|
||||
localStorage.setItem("#" + $(this)[0].id + ":value", JSON.stringify($(this)[0].value));
|
||||
});
|
||||
// Function to set dark mode on or off
|
||||
function enableDarkMode(dark) {
|
||||
$("html").attr("data-bs-theme", dark ? "dark" : "light");
|
||||
const metaThemeColor = document.querySelector("meta[name=theme-color]");
|
||||
metaThemeColor.setAttribute("content", dark ? "black" : "white");
|
||||
const metaAppleStatusBarStyle = document.querySelector("meta[name=apple-mobile-web-app-status-bar-style]");
|
||||
metaAppleStatusBarStyle.setAttribute("content", dark ? "black-translucent" : "white-translucent");
|
||||
}
|
||||
|
||||
// Load settings from local storage and set up the filter selectors
|
||||
function loadSettings() {
|
||||
// Find all local storage entries and push their data to the corresponding UI element
|
||||
Object.keys(localStorage).forEach(function(key) {
|
||||
if (key.startsWith("#") && key.includes(":")) {
|
||||
// Split the key back into an element ID and a property
|
||||
var split = key.split(":");
|
||||
$(split[0]).prop(split[1], JSON.parse(localStorage.getItem(key)));
|
||||
}
|
||||
});
|
||||
// Startup function to determine whether to use light or dark mode
|
||||
function usePreferredTheme() {
|
||||
// First, work out if we have ever explicitly saved the value of our toggle
|
||||
let val = localStorage.getItem("#darkMode:checked");
|
||||
if (val != null) {
|
||||
enableDarkMode(JSON.parse(val));
|
||||
} else {
|
||||
// Never set it before, so use the system default theme and set the toggle up to match
|
||||
let dark = window.matchMedia && window.matchMedia('(prefers-color-scheme: dark)').matches;
|
||||
enableDarkMode(dark);
|
||||
$("#darkMode").prop('checked', dark);
|
||||
}
|
||||
}
|
||||
|
||||
// Save settings to local storage. Suppressed if "embedded mode" is in use.
|
||||
function saveSettings() {
|
||||
if (!embeddedMode) {
|
||||
// Find all storeable UI elements, store a key of "element id:property name" mapped to the value of that
|
||||
// property. For a checkbox, that's the "checked" property.
|
||||
$(".storeable-checkbox").each(function() {
|
||||
localStorage.setItem("#" + $(this)[0].id + ":checked", JSON.stringify($(this)[0].checked));
|
||||
});
|
||||
$(".storeable-select").each(function() {
|
||||
localStorage.setItem("#" + $(this)[0].id + ":value", JSON.stringify($(this)[0].value));
|
||||
});
|
||||
$(".storeable-text").each(function() {
|
||||
localStorage.setItem("#" + $(this)[0].id + ":value", JSON.stringify($(this)[0].value));
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// Load settings from local storage and set up the filter selectors. Suppressed if "embedded mode" is in use.
|
||||
function loadSettings() {
|
||||
if (!embeddedMode) {
|
||||
// Find all local storage entries and push their data to the corresponding UI element
|
||||
Object.keys(localStorage).forEach(function(key) {
|
||||
if (key.startsWith("#") && key.includes(":")) {
|
||||
// Split the key back into an element ID and a property
|
||||
var split = key.split(":");
|
||||
$(split[0]).prop(split[1], JSON.parse(localStorage.getItem(key)));
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// Startup
|
||||
$(document).ready(function() {
|
||||
usePreferredTheme();
|
||||
});
|
||||
|
||||
@@ -17,7 +17,7 @@ function loadSpots() {
|
||||
// Build a query string for the API, based on the filters that the user has selected.
|
||||
function buildQueryString() {
|
||||
var str = "?";
|
||||
["dx_continent", "de_continent", "mode_type", "source", "band"].forEach(fn => {
|
||||
["dx_continent", "de_continent", "mode_type", "source", "band", "sig"].forEach(fn => {
|
||||
if (!allFilterOptionsSelected(fn)) {
|
||||
str = str + getQueryStringFor(fn) + "&";
|
||||
}
|
||||
@@ -158,6 +158,7 @@ function loadOptions() {
|
||||
|
||||
// Populate the filters panel
|
||||
generateBandsMultiToggleFilterCard(options["bands"]);
|
||||
generateSIGsMultiToggleFilterCard(options["sigs"]);
|
||||
generateMultiToggleFilterCard("#dx-continent-options", "dx_continent", options["continents"]);
|
||||
generateMultiToggleFilterCard("#de-continent-options", "de_continent", options["continents"]);
|
||||
generateMultiToggleFilterCard("#mode-options", "mode_type", options["mode_types"]);
|
||||
@@ -170,6 +171,11 @@ function loadOptions() {
|
||||
})));
|
||||
$("#max-spot-age").val(options["web-ui-options"]["max-spot-age-default"] * 60);
|
||||
|
||||
// Load URL params. These may select things from the various filter & display options, so the function needs
|
||||
// to be called after these are set up, but if the URL params ask for "embedded mode", this will suppress
|
||||
// loading settings, so this needs to be called before that.
|
||||
loadURLParams();
|
||||
|
||||
// Load settings from settings storage now all the controls are available
|
||||
loadSettings();
|
||||
|
||||
@@ -257,6 +263,6 @@ $(document).ready(function() {
|
||||
// Call loadOptions(), this will then trigger loading spots and setting up timers.
|
||||
loadOptions();
|
||||
// Prevent mouse scroll and touch actions in the popup menus being passed through to the map
|
||||
L.DomEvent.disableScrollPropagation(document.getElementById('maptools'));
|
||||
L.DomEvent.disableClickPropagation(document.getElementById('maptools'));
|
||||
L.DomEvent.disableScrollPropagation(document.getElementById('settingsButtonRowMap'));
|
||||
L.DomEvent.disableClickPropagation(document.getElementById('settingsButtonRowMap'));
|
||||
});
|
||||
@@ -14,7 +14,7 @@ function loadSpots() {
|
||||
// Build a query string for the API, based on the filters that the user has selected.
|
||||
function buildQueryString() {
|
||||
var str = "?";
|
||||
["dx_continent", "de_continent", "mode_type", "source", "band"].forEach(fn => {
|
||||
["dx_continent", "de_continent", "mode_type", "source", "band", "sig"].forEach(fn => {
|
||||
if (!allFilterOptionsSelected(fn)) {
|
||||
str = str + getQueryStringFor(fn) + "&";
|
||||
}
|
||||
@@ -46,7 +46,7 @@ function updateTable() {
|
||||
var showDE = $("#tableShowDE")[0].checked;
|
||||
|
||||
// Populate table with headers
|
||||
let table = $('<table class="table table-hover">').append('<thead><tr class="table-primary"></tr></thead><tbody></tbody>');
|
||||
let table = $('<table class="table">').append('<thead><tr class="table-primary"></tr></thead><tbody></tbody>');
|
||||
if (showTime) {
|
||||
table.find('thead tr').append(`<th>${useLocalTime ? "Local" : "UTC"}</th>`);
|
||||
}
|
||||
@@ -282,6 +282,7 @@ function loadOptions() {
|
||||
|
||||
// Populate the filters panel
|
||||
generateBandsMultiToggleFilterCard(options["bands"]);
|
||||
generateSIGsMultiToggleFilterCard(options["sigs"]);
|
||||
generateMultiToggleFilterCard("#dx-continent-options", "dx_continent", options["continents"]);
|
||||
generateMultiToggleFilterCard("#de-continent-options", "de_continent", options["continents"]);
|
||||
generateMultiToggleFilterCard("#mode-options", "mode_type", options["mode_types"]);
|
||||
@@ -294,6 +295,11 @@ function loadOptions() {
|
||||
})));
|
||||
$("#spots-to-fetch").val(options["web-ui-options"]["spot-count-default"]);
|
||||
|
||||
// Load URL params. These may select things from the various filter & display options, so the function needs
|
||||
// to be called after these are set up, but if the URL params ask for "embedded mode", this will suppress
|
||||
// loading settings, so this needs to be called before that.
|
||||
loadURLParams();
|
||||
|
||||
// Load settings from settings storage now all the controls are available
|
||||
loadSettings();
|
||||
|
||||
|
||||
@@ -30,8 +30,26 @@ function generateBandsMultiToggleFilterCard(band_options) {
|
||||
$("#band-options").append(` <span style="display: inline-block"><button id="filter-button-band-all" type="button" class="btn btn-outline-secondary" onclick="toggleFilterButtons('band', true);">All</button> <button id="filter-button-band-none" type="button" class="btn btn-outline-secondary" onclick="toggleFilterButtons('band', false);">None</button></span>`);
|
||||
}
|
||||
|
||||
// Generate SIGs filter card. This one is also a special case.
|
||||
function generateSIGsMultiToggleFilterCard(sig_options) {
|
||||
// Create a button for each option
|
||||
sig_options.forEach(o => {
|
||||
$("#sig-options").append(`<input type="checkbox" class="btn-check filter-button-sig storeable-checkbox" name="options" id="filter-button-sig-${o['name']}" value="${o['name']}" autocomplete="off" onClick="filtersUpdated()" checked><label class="btn btn-outline-primary" id="filter-button-label-sig-${o['name']}" for="filter-button-sig-${o['name']}" title="${o['description']}"><i class="fa-solid fa-${o['icon']}"></i> ${o['name']}</label> `);
|
||||
});
|
||||
// Create a bonus "NO_SIG" / "General DX" option
|
||||
$("#sig-options").append(`<input type="checkbox" class="btn-check filter-button-sig storeable-checkbox" name="options" id="filter-button-sig-NO_SIG" value="NO_SIG" autocomplete="off" onClick="filtersUpdated()" checked><label class="btn btn-outline-primary" id="filter-button-label-sig-NO_SIG" for="filter-button-sig-NO_SIG"><i class="fa-solid fa-tower-cell"></i> General DX</label> `);
|
||||
// Create All/None buttons
|
||||
$("#sig-options").append(` <span style="display: inline-block"><button id="filter-button-sig-all" type="button" class="btn btn-outline-secondary" onclick="toggleFilterButtons('sig', true);">All</button> <button id="filter-button-sig-none" type="button" class="btn btn-outline-secondary" onclick="toggleFilterButtons('sig', false);">None</button></span>`);
|
||||
}
|
||||
|
||||
// Method called when any filter is changed to reload the spots and persist the filter settings.
|
||||
function filtersUpdated() {
|
||||
loadSpots();
|
||||
saveSettings();
|
||||
}
|
||||
|
||||
// Function to set dark mode based on the state of the UI toggle in spots, bands and map pages
|
||||
function toggleDarkMode() {
|
||||
enableDarkMode($("#darkMode")[0].checked);
|
||||
saveSettings();
|
||||
}
|
||||
@@ -6,7 +6,14 @@ const CACHE_URLS = [
|
||||
'apidocs/openapi.yml',
|
||||
'about',
|
||||
'css/style.css',
|
||||
'js/code.js',
|
||||
'js/add-spot.js',
|
||||
'js/alerts.js',
|
||||
'js/bands.js',
|
||||
'js/common.js',
|
||||
'js/map.js',
|
||||
'js/spots.js',
|
||||
'js/spotsbandsandmap.js',
|
||||
'js/status.js',
|
||||
'img/logo.png',
|
||||
'img/favicon.ico',
|
||||
'img/icon-32.png',
|
||||
|
||||
Reference in New Issue
Block a user