mirror of
https://git.ianrenton.com/ian/spothole.git
synced 2026-04-29 18:25:58 +00:00
Compare commits
53 Commits
ce99bbc6cf
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
461ce94204 | ||
|
|
49949a0b2e | ||
|
|
a3332aa023 | ||
|
|
ac1ab4bd2d | ||
|
|
82944b9c38 | ||
|
|
36dba30089 | ||
|
|
1ed175e099 | ||
|
|
3870e560ec | ||
|
|
236ac1a584 | ||
|
|
9243f98604 | ||
|
|
8f062320d3 | ||
|
|
60126b0010 | ||
|
|
06c16e2f1f | ||
|
|
b3353b168c | ||
|
|
e170f9c6c2 | ||
|
|
497b84f5dc | ||
|
|
d51e5184a1 | ||
|
|
429b278bca | ||
|
|
76b0ec24b7 | ||
|
|
64afd4ed55 | ||
|
|
d71908455a | ||
|
|
c10b5e4947 | ||
|
|
4a6d9da031 | ||
|
|
9d04f8ea38 | ||
|
|
df9a82cad3 | ||
|
|
da7bb4223e | ||
|
|
8d2fcc69b0 | ||
|
|
9cfc3051a5 | ||
|
|
11dd8fa77f | ||
|
|
a44b4f5eb6 | ||
|
|
edbbb13087 | ||
|
|
c58c22d9a9 | ||
|
|
11cec58f75 | ||
|
|
9814b656b2 | ||
|
|
936e675d56 | ||
|
|
14c4e6f221 | ||
|
|
041216c5bb | ||
|
|
8257ec492d | ||
|
|
02f564b515 | ||
|
|
7de3cdc49c | ||
|
|
6f0101a861 | ||
|
|
4fe8dfc36a | ||
|
|
44f38b8114 | ||
|
|
5de5a7ffdf | ||
|
|
ed1f9e5b06 | ||
|
|
11d71629ce | ||
|
|
ee47d736eb | ||
|
|
a55179d944 | ||
|
|
8127122c11 | ||
|
|
91276067b9 | ||
|
|
126ebcb8b2 | ||
|
|
2a5e0db5bc | ||
|
|
1173af6a9d |
36
README.md
36
README.md
@@ -34,20 +34,23 @@ These are supplied with the URL to the page you want to embed, for example for a
|
||||
|
||||
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. |
|
||||
| `color-scheme` | `light`, `dark`, `auto` | `auto` | `?color-scheme=dark` | Forces light or dark mode in preference to the operating system default. |
|
||||
| `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. |
|
||||
| Name | Allowed Values | Default | Example | Description |
|
||||
|-------------------|-------------------------|---------|--------------------------------|-----------------------------------------------------------------------------------------------------------------------------------------------------------------------|
|
||||
| `embedded` | `true`, `false` | `false` | `?embedded=true` | Enables embedded mode. |
|
||||
| `color-scheme` | `light`, `dark`, `auto` | `auto` | `?color-scheme=dark` | Forces light or dark mode in preference to the operating system default. |
|
||||
| `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. |
|
||||
| `map-center-lat` | Numeric (decimal) | (auto) | `?map-center-lat=51.5` | Sets the initial latitude of the map centre on the map page. If omitted, the map auto-fits to the loaded spots. |
|
||||
| `map-center-lon` | Numeric (decimal) | (auto) | `?map-center-lon=-0.1` | Sets the initial longitude of the map centre on the map page. If omitted, the map auto-fits to the loaded spots. |
|
||||
| `map-zoom` | Numeric (integer) | (auto) | `?map-zoom=6` | Sets the initial zoom level of the map on the map page. If omitted, the map auto-fits to the loaded spots. |
|
||||
|
||||
More will be added soon to allow customisation of filters and other display properties.
|
||||
|
||||
@@ -69,6 +72,8 @@ Various approaches exist to writing your own client, but in general:
|
||||
|
||||
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.
|
||||
|
||||
You will require Python version 3.8 or later. If you encounter an error about `gdal-config` during the following process, you will also need `libgdal-dev` installed.
|
||||
|
||||
To download and set up Spothole on a Debian server, run the following commands. Other operating systems will likely be similar.
|
||||
|
||||
```bash
|
||||
@@ -301,6 +306,7 @@ To navigate your way around the source code, this list may help.
|
||||
* `/data` - Data storage classes
|
||||
* `/spotproviders` - Classes providing spots by accessing the APIs of other services
|
||||
* `/alertproviders` - Classes providing alerts by accessing the APIs of other services
|
||||
* `/solarconditionsproviders` - Classes providing solar and propagation by accessing the APIs of other services
|
||||
* `/server` - Classes for running Spothole's own web server
|
||||
|
||||
*Templates*
|
||||
@@ -355,4 +361,6 @@ The software uses a number of Python libraries as listed in `requirements.txt`,
|
||||
|
||||
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.
|
||||
|
||||
Amateur radio clusters, outdoor programmes, propagation data providers etc. are almost all volunteer-run services that make no or little profit, and are done for the love of amateur radio. Services like Spothole, which build on top of them, are truly standing on the shoulders of giants. None of this would have been possible without the hard work and dedication of many other people within the amaetur radio community.
|
||||
|
||||
The project's name was suggested by Harm, DK4HAA. Thanks!
|
||||
|
||||
@@ -2,7 +2,7 @@ import re
|
||||
from datetime import datetime
|
||||
|
||||
import pytz
|
||||
from rss_parser import RSSParser
|
||||
from rss_parser import Parser
|
||||
|
||||
from alertproviders.http_alert_provider import HTTPAlertProvider
|
||||
from data.alert import Alert
|
||||
@@ -20,7 +20,7 @@ class NG3K(HTTPAlertProvider):
|
||||
|
||||
def _http_response_to_alerts(self, http_response):
|
||||
new_alerts = []
|
||||
rss = RSSParser.parse(http_response.content.decode())
|
||||
rss = Parser.parse(http_response.content.decode())
|
||||
# Iterate through source data
|
||||
for source_alert in rss.channel.items:
|
||||
# Deal with "the format"...
|
||||
@@ -68,9 +68,9 @@ class NG3K(HTTPAlertProvider):
|
||||
|
||||
dx_country = parts[1]
|
||||
qsl_info = parts[3]
|
||||
bands = extra_parts[1]
|
||||
modes = extra_parts[2] if len(extra_parts) > 3 else ""
|
||||
comment = extra_parts[-1]
|
||||
bands = extra_parts[1] if len(extra_parts) > 1 else ""
|
||||
modes = extra_parts[2] if len(extra_parts) > 2 else ""
|
||||
comment = extra_parts[3] if len(extra_parts) > 3 else ""
|
||||
|
||||
# Convert to our alert format
|
||||
alert = Alert(source=self.name,
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
from datetime import datetime
|
||||
|
||||
import pytz
|
||||
from rss_parser import RSSParser
|
||||
from rss_parser import Parser as RSSParser
|
||||
|
||||
from alertproviders.http_alert_provider import HTTPAlertProvider
|
||||
from data.alert import Alert
|
||||
|
||||
@@ -121,12 +121,19 @@ spot-providers:
|
||||
class: "XOTA"
|
||||
name: "39C3 TOTA"
|
||||
enabled: false
|
||||
url: "wss://dev.39c3.totawatch.de/api/spot/live"
|
||||
url: "wss://39c3.totawatch.de/api/spot/live"
|
||||
# Fixed SIG for all spots from a provider & location CSV are 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"
|
||||
locations-csv: "datafiles/39c3-tota.csv"
|
||||
-
|
||||
class: "XOTA"
|
||||
name: "EH23 TOTA"
|
||||
enabled: true
|
||||
url: "wss://eh23.totawatch.de/api/spot/live"
|
||||
sig: "TOTA"
|
||||
locations-csv: "datafiles/eh23-tota.csv"
|
||||
|
||||
|
||||
# Alert providers to use. Same setup as the spot providers list above.
|
||||
@@ -160,6 +167,19 @@ alert-providers:
|
||||
name: "NG3K"
|
||||
enabled: true
|
||||
|
||||
|
||||
# Solar condition providers to use. These poll external APIs for solar propagation data (SFI, A/K indices, band
|
||||
# conditions, etc.) and make it available via the /api/v1/solar endpoint.
|
||||
solar-condition-providers:
|
||||
-
|
||||
class: "HamQSL"
|
||||
name: "HamQSL"
|
||||
enabled: true
|
||||
-
|
||||
class: "NOAA3dayForecast"
|
||||
name: "NOAA 3-day Forecast"
|
||||
enabled: true
|
||||
|
||||
# Port to open the local web server on
|
||||
web-server-port: 8080
|
||||
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import json
|
||||
import logging
|
||||
import re
|
||||
from math import floor
|
||||
@@ -11,8 +12,10 @@ TRANSFORMER_OS_GRID_TO_WGS84 = Transformer.from_crs("EPSG:27700", "EPSG:4326")
|
||||
TRANSFORMER_IRISH_GRID_TO_WGS84 = Transformer.from_crs("EPSG:29903", "EPSG:4326")
|
||||
TRANSFORMER_CI_UTM_GRID_TO_WGS84 = Transformer.from_crs("+proj=utm +zone=30 +ellps=WGS84", "EPSG:4326")
|
||||
|
||||
cq_zone_data = geopandas.GeoDataFrame.from_features(geopandas.read_file("datafiles/cqzones.geojson"))
|
||||
itu_zone_data = geopandas.GeoDataFrame.from_features(geopandas.read_file("datafiles/ituzones.geojson"))
|
||||
with open("datafiles/cqzones.geojson") as f:
|
||||
cq_zone_data = geopandas.GeoDataFrame.from_features(json.load(f)["features"])
|
||||
with open("datafiles/ituzones.geojson") as f:
|
||||
itu_zone_data = geopandas.GeoDataFrame.from_features(json.load(f)["features"])
|
||||
for idx in cq_zone_data.index:
|
||||
prepare(cq_zone_data.at[idx, 'geometry'])
|
||||
for idx in itu_zone_data.index:
|
||||
|
||||
@@ -14,7 +14,7 @@ class StatusReporter:
|
||||
"""Provides a timed update of the application's status data."""
|
||||
|
||||
def __init__(self, status_data, run_interval, web_server, cleanup_timer, spots, spot_providers, alerts,
|
||||
alert_providers):
|
||||
alert_providers, solar_condition_providers):
|
||||
"""Constructor"""
|
||||
|
||||
self._status_data = status_data
|
||||
@@ -25,6 +25,7 @@ class StatusReporter:
|
||||
self._spot_providers = spot_providers
|
||||
self._alerts = alerts
|
||||
self._alert_providers = alert_providers
|
||||
self._solar_condition_providers = solar_condition_providers
|
||||
self._thread = None
|
||||
self._stop_event = Event()
|
||||
self._startup_time = datetime.now(pytz.UTC)
|
||||
@@ -70,6 +71,11 @@ class StatusReporter:
|
||||
"last_updated": p.last_update_time.replace(
|
||||
tzinfo=pytz.UTC).timestamp() if p.last_update_time.year > 2000 else 0},
|
||||
self._alert_providers))
|
||||
self._status_data["solar_condition_providers"] = list(
|
||||
map(lambda p: {"name": p.name, "enabled": p.enabled, "status": p.status,
|
||||
"last_updated": p.last_update_time.replace(
|
||||
tzinfo=pytz.UTC).timestamp() if p.last_update_time.year > 2000 else 0},
|
||||
self._solar_condition_providers))
|
||||
self._status_data["cleanup"] = {"status": self._cleanup_timer.status,
|
||||
"last_ran": self._cleanup_timer.last_cleanup_time.replace(
|
||||
tzinfo=pytz.UTC).timestamp() if self._cleanup_timer.last_cleanup_time else 0}
|
||||
|
||||
199
data/solar_conditions.py
Normal file
199
data/solar_conditions.py
Normal file
@@ -0,0 +1,199 @@
|
||||
import json
|
||||
from dataclasses import dataclass
|
||||
|
||||
# Lookup tables for derived text descriptions.
|
||||
# Each threshold-based table is a list of (min_value, description) pairs in descending order;
|
||||
# the first entry whose threshold the value meets or exceeds is used.
|
||||
|
||||
XRAY_CLASS_DESCRIPTIONS = {
|
||||
"X": "Wide area HF radio blackout across sunlit side",
|
||||
"M": "Occasional loss of HF communications on sunlit side",
|
||||
"C": "Low absorption of HF signals on sunlit side",
|
||||
"B": "No significant radio blackout",
|
||||
"A": "No impact",
|
||||
}
|
||||
|
||||
PROTON_FLUX_DESCRIPTIONS = [
|
||||
(1000000, "Complete HF blackout in polar regions"),
|
||||
(100000, "Partial HF blackout in polar regions"),
|
||||
(10000, "Degraded HF propagation in polar regions"),
|
||||
(1000, "Small effect on HF propagation in polar regions"),
|
||||
(100, "Minor effect on HF propagation in polar regions"),
|
||||
(10, "Very minor effect on HF propagation in polar regions"),
|
||||
(0, "No impact"),
|
||||
]
|
||||
|
||||
SOLAR_STORM_SCALES = [
|
||||
(100000, 5),
|
||||
(10000, 4),
|
||||
(1000, 3),
|
||||
(100, 2),
|
||||
(10, 1),
|
||||
(0, 0),
|
||||
]
|
||||
|
||||
GEOMAG_STORM_DESCRIPTIONS = [
|
||||
(9, "Complete HF blackout"),
|
||||
(8, "HF sporadic only"),
|
||||
(7, "HF intermittent"),
|
||||
(6, "HF fading at higher latitudes"),
|
||||
(5, "HF fading at higher latitudes"),
|
||||
(4, "Minor HF fading at higher latitudes"),
|
||||
(3, "Minor HF fading at higher latitudes"),
|
||||
(2, "No impact"),
|
||||
(1, "No impact"),
|
||||
(0, "No impact"),
|
||||
]
|
||||
|
||||
GEOMAG_STORM_SCALES = [
|
||||
(9, 5),
|
||||
(8, 4),
|
||||
(7, 3),
|
||||
(6, 2),
|
||||
(5, 1),
|
||||
(0, 0),
|
||||
]
|
||||
|
||||
BAND_CONDITIONS_DESCRIPTIONS = [
|
||||
(200, "Reliable conditions on all bands including 6m"),
|
||||
(150, "Excellent conditions on all bands up to 10m, occasional 6m openings"),
|
||||
(120, "Fair to good conditions on all bands up to 10m"),
|
||||
(90, "Fair conditions on bands up to 15m"),
|
||||
(70, "Poor to fair conditions on bands up to 20m"),
|
||||
(0, "Bands above 40m unusable"),
|
||||
]
|
||||
|
||||
ELECTRON_FLUX_DESCRIPTIONS = [
|
||||
(1000, "Partial to complete HF blackout in polar regions"),
|
||||
(100, "Degraded HF propagation in polar regions"),
|
||||
(10, "Minor impact on HF in polar regions"),
|
||||
(0, "No impact"),
|
||||
]
|
||||
|
||||
|
||||
def _xray_blackout_scale(xray):
|
||||
"""Return the NOAA Radio Blackout scale number (R0-R5) for the given X-ray flux class string
|
||||
(e.g. "M4.5", "X12")."""
|
||||
|
||||
if not xray or len(xray) < 2:
|
||||
return 0
|
||||
letter = xray[0].upper()
|
||||
try:
|
||||
number = float(xray[1:])
|
||||
except ValueError:
|
||||
return 0
|
||||
if letter == 'M':
|
||||
return 1 if number < 5 else 2
|
||||
if letter == 'X':
|
||||
if number < 10:
|
||||
return 3
|
||||
if number < 20:
|
||||
return 4
|
||||
return 5
|
||||
return 0
|
||||
|
||||
|
||||
def _lookup_by_threshold(value, table, default=None):
|
||||
"""Return the description from a threshold table for the given numeric value.
|
||||
The table is a list of (min_value, description) pairs in descending order."""
|
||||
|
||||
if value is None:
|
||||
return default
|
||||
for threshold, description in table:
|
||||
if value >= threshold:
|
||||
return description
|
||||
return default
|
||||
|
||||
|
||||
@dataclass
|
||||
class HFBandCondition:
|
||||
"""Data class representing HF propagation conditions for certain bands and time of day."""
|
||||
|
||||
# Band name, e.g. "80m-40m", "20m-17m", "10m-6m"
|
||||
band: str = None
|
||||
# Time of day: "day" or "night"
|
||||
time: str = None
|
||||
# Propagation condition: "Good", "Fair", or "Poor"
|
||||
condition: str = None
|
||||
|
||||
|
||||
@dataclass
|
||||
class SolarConditions:
|
||||
"""Data class representing current solar and propagation conditions."""
|
||||
|
||||
# Time the data was last updated at the source, UTC seconds since UNIX epoch
|
||||
updated: float = None
|
||||
# Solar Flux Index (SFI)
|
||||
sfi: int = None
|
||||
# A-index (daily geomagnetic activity)
|
||||
a_index: int = None
|
||||
# K-index (3-hour geomagnetic activity)
|
||||
k_index: int = None
|
||||
# X-ray flux class, e.g. "B2.3", "C1.0"
|
||||
xray: str = None
|
||||
# Proton flux
|
||||
proton_flux: int = None
|
||||
# Electron flux
|
||||
electron_flux: int = None
|
||||
# Aurora activity level
|
||||
aurora: int = None
|
||||
# Latitude in degrees of the aurora boundary
|
||||
aurora_latitude: float = None
|
||||
# Sunspot count
|
||||
sunspots: int = None
|
||||
# Solar wind speed in km/s
|
||||
solar_wind: float = None
|
||||
# Interplanetary magnetic field strength in nT
|
||||
magnetic_field: float = None
|
||||
# Geomagnetic field condition, e.g. "Quiet", "Unsettled", "Active", "Storm"
|
||||
geomag_field: str = None
|
||||
# Geomagnetic background noise level, e.g. "S0", "S1", "S2"
|
||||
geomag_noise: str = None
|
||||
# HF band propagation conditions, keyed by "{band}-{time}" e.g. "80m-40m-day"
|
||||
hf_conditions: dict = None
|
||||
# VHF propagation conditions, keyed by condition name
|
||||
vhf_conditions: dict = None
|
||||
# NOAA Kp index 3-day forecast, keyed by UNIX timestamp of the start of each 3-hour UTC period
|
||||
k_index_forecast: dict = None
|
||||
# NOAA Solar Radiation Storm (S1 or greater) probability forecast, keyed by UNIX timestamp of start of day UTC
|
||||
solar_storm_forecast: dict = None
|
||||
# NOAA Radio Blackout (R1-R2) probability forecast, keyed by UNIX timestamp of start of day UTC
|
||||
blackout_forecast_r1r2: dict = None
|
||||
# NOAA Radio Blackout (R3 or greater) probability forecast, keyed by UNIX timestamp of start of day UTC
|
||||
blackout_forecast_r3_or_greater: dict = None
|
||||
|
||||
# Derived values (populated by infer_descriptions())
|
||||
# HF radio blackout risk description, derived from xray
|
||||
xray_desc: str = None
|
||||
# HF radio blackout scale number (R0-R5), derived from xray
|
||||
radio_blackout_scale: int = None
|
||||
# Solar radiation storm level description, derived from proton_flux
|
||||
proton_flux_desc: str = None
|
||||
# Solar radiation storm scale number (S0-S5), derived from proton_flux
|
||||
solar_storm_scale: int = None
|
||||
# Geomagnetic storm level description, derived from k_index
|
||||
geomag_storm_desc: str = None
|
||||
# Geomagnetic storm scale number (G0-G5), derived from k_index
|
||||
geomag_storm_scale: int = None
|
||||
# Overall HF band conditions summary, derived from sfi
|
||||
band_conditions_desc: str = None
|
||||
# Electron flux description, derived from electron_flux
|
||||
electron_flux_desc: str = None
|
||||
|
||||
def infer_descriptions(self):
|
||||
"""Populate derived text description fields from the current numeric/raw field values."""
|
||||
|
||||
if self.xray and len(self.xray) > 0:
|
||||
self.xray_desc = XRAY_CLASS_DESCRIPTIONS.get(self.xray[0].upper())
|
||||
self.radio_blackout_scale = _xray_blackout_scale(self.xray)
|
||||
self.proton_flux_desc = _lookup_by_threshold(self.proton_flux, PROTON_FLUX_DESCRIPTIONS)
|
||||
self.solar_storm_scale = _lookup_by_threshold(self.proton_flux, SOLAR_STORM_SCALES)
|
||||
self.geomag_storm_desc = _lookup_by_threshold(self.k_index, GEOMAG_STORM_DESCRIPTIONS)
|
||||
self.geomag_storm_scale = _lookup_by_threshold(self.k_index, GEOMAG_STORM_SCALES)
|
||||
self.band_conditions_desc = _lookup_by_threshold(self.sfi, BAND_CONDITIONS_DESCRIPTIONS)
|
||||
self.electron_flux_desc = _lookup_by_threshold(self.electron_flux, ELECTRON_FLUX_DESCRIPTIONS)
|
||||
|
||||
def to_json(self):
|
||||
"""JSON serialise"""
|
||||
|
||||
return json.dumps(self, default=lambda o: o.__dict__, sort_keys=True)
|
||||
14
data/spot.py
14
data/spot.py
@@ -200,6 +200,10 @@ class Spot:
|
||||
if self.de_dxcc_id and not self.de_flag:
|
||||
self.de_flag = lookup_helper.get_flag_for_dxcc(self.de_dxcc_id)
|
||||
|
||||
# Remove NaNs in frequency
|
||||
if self.freq and self.freq == float("nan"):
|
||||
self.freq = None
|
||||
|
||||
# Band from frequency
|
||||
if self.freq and not self.band:
|
||||
band = infer_band_from_freq(self.freq)
|
||||
@@ -333,6 +337,16 @@ class Spot:
|
||||
self.dx_grid = lookup_helper.infer_grid_from_callsign_dxcc(self.dx_call)
|
||||
self.dx_location_source = "DXCC"
|
||||
|
||||
# It looks like we can sometimes get a string into lat/lon, so try to parse as float, reject if not valid
|
||||
if isinstance(self.dx_latitude, str) or isinstance(self.dx_longitude, str):
|
||||
try:
|
||||
self.dx_latitude = float(self.dx_latitude)
|
||||
self.dx_longitude = float(self.dx_longitude)
|
||||
except (TypeError, ValueError):
|
||||
logging.warning("Received non-numeric strings in lat/lon (" + str(self.dx_latitude) + ", " + str(self.dx_longitude) + ") for call " + self.dx_call + ", rejecting it")
|
||||
self.dx_latitude = None
|
||||
self.dx_longitude = None
|
||||
|
||||
# CQ and ITU zone lookup, preferably from location but failing that, from callsign
|
||||
if not self.dx_cq_zone:
|
||||
if self.dx_latitude:
|
||||
|
||||
13
datafiles/eh23-tota.csv
Normal file
13
datafiles/eh23-tota.csv
Normal file
@@ -0,0 +1,13 @@
|
||||
ref,lat,lon
|
||||
T-01,50.3636495,7.5584857
|
||||
T-02,50.3636495,7.5584857
|
||||
T-03,50.3636495,7.5584857
|
||||
T-11,50.3636495,7.5584857
|
||||
T-13,50.3636495,7.5584857
|
||||
T-14,50.3636495,7.5584857
|
||||
T-21,50.3636495,7.5584857
|
||||
T-31,50.3636495,7.5584857
|
||||
T-33,50.3636495,7.5584857
|
||||
T-34,50.3636495,7.5584857
|
||||
T-41,50.3636495,7.5584857
|
||||
T-51,50.3636495,7.5584857
|
||||
|
Binary file not shown.
|
Before Width: | Height: | Size: 189 KiB After Width: | Height: | Size: 194 KiB |
@@ -3,16 +3,17 @@ requests-cache~=1.2.1
|
||||
pyhamtools~=0.12.0
|
||||
telnetlib3~=2.0.8
|
||||
pytz~=2025.2
|
||||
requests~=2.32.5
|
||||
requests~=2.32.4
|
||||
aprslib~=0.7.2
|
||||
diskcache~=5.6.3
|
||||
psutil~=7.1.0
|
||||
requests-sse~=0.5.2
|
||||
rss-parser~=2.1.1
|
||||
pyproj~=3.7.2
|
||||
prometheus_client~=0.23.1
|
||||
rss-parser~=1.1.1
|
||||
pyproj~=3.5.0;python_version<="3.8"
|
||||
pyproj~=3.7.2;python_version>"3.8"
|
||||
prometheus_client~=0.21.1
|
||||
beautifulsoup4~=4.14.2
|
||||
websocket-client~=1.9.0
|
||||
tornado~=6.5.4
|
||||
websocket-client~=1.8.0
|
||||
tornado~=6.4.2
|
||||
tornado_eventsource~=3.0.0
|
||||
geopandas~=1.1.2
|
||||
geopandas~=0.13.2
|
||||
@@ -147,7 +147,7 @@ def alert_allowed_by_query(alert, query):
|
||||
for k in query.keys():
|
||||
match k:
|
||||
case "received_since":
|
||||
since = datetime.fromtimestamp(int(query.get(k)), pytz.UTC)
|
||||
since = datetime.fromtimestamp(float(query.get(k)), pytz.UTC)
|
||||
if not alert.received_time or alert.received_time <= since:
|
||||
return False
|
||||
case "max_duration":
|
||||
|
||||
49
server/handlers/api/dxstats.py
Normal file
49
server/handlers/api/dxstats.py
Normal file
@@ -0,0 +1,49 @@
|
||||
import json
|
||||
from collections import Counter
|
||||
from datetime import datetime, timedelta
|
||||
|
||||
import pytz
|
||||
import tornado
|
||||
|
||||
from core.prometheus_metrics_handler import api_requests_counter
|
||||
|
||||
CONTINENTS = ["EU", "NA", "SA", "AS", "AF", "OC", "AN"]
|
||||
BANDS = ["160m", "80m", "60m", "40m", "30m", "20m", "17m", "15m", "12m", "10m", "6m"]
|
||||
CONTINENTS_SET = frozenset(CONTINENTS)
|
||||
BANDS_SET = frozenset(BANDS)
|
||||
|
||||
|
||||
class APIDxStatsHandler(tornado.web.RequestHandler):
|
||||
"""API request handler for /api/v1/dxstats"""
|
||||
|
||||
def initialize(self, spots, web_server_metrics):
|
||||
self._spots = spots
|
||||
self._web_server_metrics = web_server_metrics
|
||||
|
||||
def get(self):
|
||||
self._web_server_metrics["last_api_access_time"] = datetime.now(pytz.UTC)
|
||||
self._web_server_metrics["api_access_counter"] += 1
|
||||
self._web_server_metrics["status"] = "OK"
|
||||
api_requests_counter.inc()
|
||||
|
||||
one_hour_ago = (datetime.now(pytz.UTC) - timedelta(hours=1)).timestamp()
|
||||
counts = Counter()
|
||||
|
||||
for key in self._spots.iterkeys():
|
||||
spot = self._spots.get(key)
|
||||
if spot is None:
|
||||
continue
|
||||
if not spot.time or spot.time < one_hour_ago:
|
||||
continue
|
||||
if spot.de_continent in CONTINENTS_SET and spot.dx_continent in CONTINENTS_SET and spot.band in BANDS_SET:
|
||||
counts[spot.de_continent, spot.dx_continent, spot.band] += 1
|
||||
|
||||
result = {
|
||||
de: {dx: {band: counts[de, dx, band] for band in BANDS} for dx in CONTINENTS}
|
||||
for de in CONTINENTS
|
||||
}
|
||||
|
||||
self.write(json.dumps(result))
|
||||
self.set_status(200)
|
||||
self.set_header("Cache-Control", "no-store")
|
||||
self.set_header("Content-Type", "application/json")
|
||||
28
server/handlers/api/solar_conditions.py
Normal file
28
server/handlers/api/solar_conditions.py
Normal file
@@ -0,0 +1,28 @@
|
||||
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):
|
||||
"""API request handler for /api/v1/solar"""
|
||||
|
||||
def initialize(self, solar_conditions, web_server_metrics):
|
||||
self._solar_conditions = solar_conditions
|
||||
self._web_server_metrics = web_server_metrics
|
||||
|
||||
def get(self):
|
||||
# Metrics
|
||||
self._web_server_metrics["last_api_access_time"] = datetime.now(pytz.UTC)
|
||||
self._web_server_metrics["api_access_counter"] += 1
|
||||
self._web_server_metrics["status"] = "OK"
|
||||
api_requests_counter.inc()
|
||||
|
||||
self.write(self._solar_conditions.to_json())
|
||||
self.set_status(200)
|
||||
self.set_header("Cache-Control", "no-store")
|
||||
self.set_header("Content-Type", "application/json")
|
||||
@@ -178,7 +178,7 @@ def spot_allowed_by_query(spot, query):
|
||||
if not spot.time or spot.time <= since:
|
||||
return False
|
||||
case "received_since":
|
||||
since = datetime.fromtimestamp(int(query.get(k)), pytz.UTC).timestamp()
|
||||
since = datetime.fromtimestamp(float(query.get(k)), pytz.UTC).timestamp()
|
||||
if not spot.received_time or spot.received_time <= since:
|
||||
return False
|
||||
case "source":
|
||||
|
||||
@@ -11,9 +11,11 @@ from core.prometheus_metrics_handler import page_requests_counter
|
||||
class PageTemplateHandler(tornado.web.RequestHandler):
|
||||
"""Handler for all HTML pages generated from templates"""
|
||||
|
||||
def initialize(self, template_name, web_server_metrics):
|
||||
def initialize(self, template_name, web_server_metrics, has_hamqsl=False, has_noaa_forecast=False):
|
||||
self._template_name = template_name
|
||||
self._web_server_metrics = web_server_metrics
|
||||
self._has_hamqsl = has_hamqsl
|
||||
self._has_noaa_forecast = has_noaa_forecast
|
||||
|
||||
def get(self):
|
||||
# Metrics
|
||||
@@ -24,4 +26,5 @@ class PageTemplateHandler(tornado.web.RequestHandler):
|
||||
|
||||
# Load named template, and provide variables used in templates
|
||||
self.render(self._template_name + ".html", software_version=SOFTWARE_VERSION, allow_spotting=ALLOW_SPOTTING,
|
||||
web_ui_options=WEB_UI_OPTIONS, baseurl = BASE_URL, current_path=self.request.path)
|
||||
web_ui_options=WEB_UI_OPTIONS, baseurl=BASE_URL, current_path=self.request.path,
|
||||
has_hamqsl=self._has_hamqsl, has_noaa_forecast=self._has_noaa_forecast)
|
||||
@@ -7,9 +7,11 @@ from tornado.web import StaticFileHandler
|
||||
|
||||
from core.utils import empty_queue
|
||||
from server.handlers.api.addspot import APISpotHandler
|
||||
from server.handlers.api.dxstats import APIDxStatsHandler
|
||||
from server.handlers.api.alerts import APIAlertsHandler, APIAlertsStreamHandler
|
||||
from server.handlers.api.lookups import APILookupCallHandler, APILookupSIGRefHandler, APILookupGridHandler
|
||||
from server.handlers.api.options import APIOptionsHandler
|
||||
from server.handlers.api.solar_conditions import APISolarConditionsHandler
|
||||
from server.handlers.api.spots import APISpotsHandler, APISpotsStreamHandler
|
||||
from server.handlers.api.status import APIStatusHandler
|
||||
from server.handlers.metrics import PrometheusMetricsHandler
|
||||
@@ -19,14 +21,16 @@ from server.handlers.pagetemplate import PageTemplateHandler
|
||||
class WebServer:
|
||||
"""Provides the public-facing web server."""
|
||||
|
||||
def __init__(self, spots, alerts, status_data, port):
|
||||
def __init__(self, spots, alerts, solar_conditions, status_data, solar_condition_providers, port):
|
||||
"""Constructor"""
|
||||
|
||||
self._spots = spots
|
||||
self._alerts = alerts
|
||||
self._solar_conditions = solar_conditions
|
||||
self._sse_spot_queues = []
|
||||
self._sse_alert_queues = []
|
||||
self._status_data = status_data
|
||||
self._solar_condition_providers = solar_condition_providers
|
||||
self._port = port
|
||||
self._shutdown_event = asyncio.Event()
|
||||
self.web_server_metrics = {
|
||||
@@ -50,6 +54,12 @@ class WebServer:
|
||||
async def _start_inner(self):
|
||||
"""Start method (async). Sets up the Tornado application."""
|
||||
|
||||
provider_classes = [type(p).__name__ for p in self._solar_condition_providers if p.enabled]
|
||||
has_hamqsl = "HamQSL" in provider_classes
|
||||
has_noaa_forecast = "NOAA3dayForecast" in provider_classes
|
||||
page_opts = {"web_server_metrics": self.web_server_metrics, "has_hamqsl": has_hamqsl,
|
||||
"has_noaa_forecast": has_noaa_forecast}
|
||||
|
||||
app = tornado.web.Application([
|
||||
# Routes for API calls
|
||||
(r"/api/v1/spots", APISpotsHandler, {"spots": self._spots, "web_server_metrics": self.web_server_metrics}),
|
||||
@@ -59,6 +69,9 @@ class WebServer:
|
||||
{"sse_spot_queues": self._sse_spot_queues, "web_server_metrics": self.web_server_metrics}),
|
||||
(r"/api/v1/alerts/stream", APIAlertsStreamHandler,
|
||||
{"sse_alert_queues": self._sse_alert_queues, "web_server_metrics": self.web_server_metrics}),
|
||||
(r"/api/v1/solar", APISolarConditionsHandler,
|
||||
{"solar_conditions": self._solar_conditions, "web_server_metrics": self.web_server_metrics}),
|
||||
(r"/api/v1/dxstats", APIDxStatsHandler, {"spots": self._spots, "web_server_metrics": self.web_server_metrics}),
|
||||
(r"/api/v1/options", APIOptionsHandler,
|
||||
{"status_data": self._status_data, "web_server_metrics": self.web_server_metrics}),
|
||||
(r"/api/v1/status", APIStatusHandler,
|
||||
@@ -68,18 +81,15 @@ class WebServer:
|
||||
(r"/api/v1/lookup/grid", APILookupGridHandler, {"web_server_metrics": self.web_server_metrics}),
|
||||
(r"/api/v1/spot", APISpotHandler, {"spots": self._spots, "web_server_metrics": self.web_server_metrics}),
|
||||
# Routes for templated pages
|
||||
(r"/", PageTemplateHandler, {"template_name": "spots", "web_server_metrics": self.web_server_metrics}),
|
||||
(r"/map", PageTemplateHandler, {"template_name": "map", "web_server_metrics": self.web_server_metrics}),
|
||||
(r"/bands", PageTemplateHandler, {"template_name": "bands", "web_server_metrics": self.web_server_metrics}),
|
||||
(r"/alerts", PageTemplateHandler,
|
||||
{"template_name": "alerts", "web_server_metrics": self.web_server_metrics}),
|
||||
(r"/add-spot", PageTemplateHandler,
|
||||
{"template_name": "add_spot", "web_server_metrics": self.web_server_metrics}),
|
||||
(r"/status", PageTemplateHandler,
|
||||
{"template_name": "status", "web_server_metrics": self.web_server_metrics}),
|
||||
(r"/about", PageTemplateHandler, {"template_name": "about", "web_server_metrics": self.web_server_metrics}),
|
||||
(r"/apidocs", PageTemplateHandler,
|
||||
{"template_name": "apidocs", "web_server_metrics": self.web_server_metrics}),
|
||||
(r"/", PageTemplateHandler, {"template_name": "spots", **page_opts}),
|
||||
(r"/map", PageTemplateHandler, {"template_name": "map", **page_opts}),
|
||||
(r"/bands", PageTemplateHandler, {"template_name": "bands", **page_opts}),
|
||||
(r"/alerts", PageTemplateHandler, {"template_name": "alerts", **page_opts}),
|
||||
(r"/add-spot", PageTemplateHandler, {"template_name": "add_spot", **page_opts}),
|
||||
(r"/conditions", PageTemplateHandler, {"template_name": "conditions", **page_opts}),
|
||||
(r"/status", PageTemplateHandler, {"template_name": "status", **page_opts}),
|
||||
(r"/about", PageTemplateHandler, {"template_name": "about", **page_opts}),
|
||||
(r"/apidocs", PageTemplateHandler, {"template_name": "apidocs", **page_opts}),
|
||||
# Route for Prometheus metrics
|
||||
(r"/metrics", PrometheusMetricsHandler),
|
||||
# Default route to serve from "webassets"
|
||||
|
||||
113
solarconditionsproviders/hamqsl.py
Normal file
113
solarconditionsproviders/hamqsl.py
Normal file
@@ -0,0 +1,113 @@
|
||||
import logging
|
||||
from xml.etree import ElementTree
|
||||
|
||||
import pytz
|
||||
from dateutil import parser as dateutil_parser, tz as dateutil_tz
|
||||
|
||||
|
||||
from solarconditionsproviders.http_solar_conditions_provider import HTTPSolarConditionsProvider
|
||||
|
||||
POLL_INTERVAL = 3600 # 1 hour
|
||||
URL = "https://www.hamqsl.com/solarxml.php"
|
||||
|
||||
|
||||
class HamQSL(HTTPSolarConditionsProvider):
|
||||
"""Solar conditions provider using the HamQSL.com XML API (https://www.hamqsl.com/solarxml.php).
|
||||
Provides solar flux index, geomagnetic indices, and HF/VHF propagation condition summaries."""
|
||||
|
||||
def __init__(self, provider_config):
|
||||
super().__init__(provider_config, URL, POLL_INTERVAL)
|
||||
|
||||
def _http_response_to_solar_conditions(self, http_response):
|
||||
if http_response.status_code != 200:
|
||||
logging.warning("HamQSL solar conditions API returned HTTP " + str(http_response.status_code))
|
||||
return None
|
||||
|
||||
root = ElementTree.fromstring(http_response.text)
|
||||
sd = root.find("solardata")
|
||||
if sd is None:
|
||||
logging.warning("HamQSL solar conditions API returned unexpected XML structure")
|
||||
return None
|
||||
|
||||
# Some error checking functions in case the data is janky.
|
||||
|
||||
def text(tag, default=None):
|
||||
el = sd.find(tag)
|
||||
return el.text.strip() if el is not None and el.text else default
|
||||
|
||||
def float_val(tag, default=None):
|
||||
try:
|
||||
return float(text(tag))
|
||||
except (ValueError, TypeError):
|
||||
return default
|
||||
|
||||
def int_val(tag, default=None):
|
||||
try:
|
||||
return int(text(tag))
|
||||
except (ValueError, TypeError):
|
||||
return default
|
||||
|
||||
# Process HF band conditions
|
||||
hf_conditions = {}
|
||||
calc = sd.find("calculatedconditions")
|
||||
if calc is not None:
|
||||
for band_el in calc.findall("band"):
|
||||
name = band_el.get("name")
|
||||
time = band_el.get("time")
|
||||
condition = band_el.text.strip() if band_el.text else None
|
||||
if name and time and condition:
|
||||
hf_conditions[f"{name}-{time}"] = condition
|
||||
|
||||
# Process VHF propagation conditions
|
||||
vhf_map = {}
|
||||
vhf = sd.find("calculatedvhfconditions")
|
||||
if vhf is not None:
|
||||
for ph_el in vhf.findall("phenomenon"):
|
||||
key = (ph_el.get("name"), ph_el.get("location"))
|
||||
vhf_map[key] = ph_el.text.strip() if ph_el.text else None
|
||||
|
||||
# Parse the "updated" timestamp string (format: "28 Mar 2026 0949 GMT") to UTC epoch seconds.
|
||||
updated = None
|
||||
updated_str = text("updated")
|
||||
if updated_str:
|
||||
try:
|
||||
tz_abbr = updated_str.split()[-1]
|
||||
timezone = dateutil_tz.gettz(tz_abbr)
|
||||
if timezone is None:
|
||||
raise ValueError("Unknown timezone abbreviation: " + tz_abbr)
|
||||
dt = dateutil_parser.parse(updated_str, tzinfos={tz_abbr: timezone})
|
||||
updated = dt.astimezone(pytz.UTC).timestamp()
|
||||
except (ValueError, IndexError):
|
||||
logging.warning("HamQSL solar conditions API returned unrecognised timestamp format: " + updated_str)
|
||||
|
||||
# Return the data ready to be put into the solar conditions object.
|
||||
return {
|
||||
"updated": updated,
|
||||
"sfi": int_val("solarflux"),
|
||||
"a_index": int_val("aindex"),
|
||||
"k_index": int_val("kindex"),
|
||||
"xray": text("xray"),
|
||||
"sunspots": int_val("sunspots"),
|
||||
"proton_flux": int_val("protonflux"),
|
||||
"electron_flux": int_val("electonflux"),
|
||||
"aurora": int_val("aurora"),
|
||||
"aurora_latitude": float_val("latdegree"),
|
||||
"solar_wind": float_val("solarwind"),
|
||||
"magnetic_field": float_val("magneticfield"),
|
||||
"geomag_field": text("geomagfield").title()
|
||||
.replace("Vr Quiet", "Very Quiet")
|
||||
.replace("Unsettld", "Unsettled")
|
||||
.replace("Min Strm", "Minor Storm")
|
||||
.replace("Maj Strm", "Major Storm")
|
||||
.replace("Sev Strm", "Severe Storm")
|
||||
.replace("Ext Strm", "Extreme Storm"),
|
||||
"geomag_noise": text("signalnoise"),
|
||||
"hf_conditions": hf_conditions,
|
||||
"vhf_conditions": {
|
||||
"vhf_aurora_northern_hemi": vhf_map.get(("vhf-aurora", "northern_hemi")).title().replace("Lat Aur", "Latitude"),
|
||||
"es_2m_europe": vhf_map.get(("E-Skip", "europe")),
|
||||
"es_4m_europe": vhf_map.get(("E-Skip", "europe_4m")),
|
||||
"es_6m_europe": vhf_map.get(("E-Skip", "europe_6m")),
|
||||
"es_2m_na": vhf_map.get(("E-Skip", "north_america")),
|
||||
},
|
||||
}
|
||||
59
solarconditionsproviders/http_solar_conditions_provider.py
Normal file
59
solarconditionsproviders/http_solar_conditions_provider.py
Normal file
@@ -0,0 +1,59 @@
|
||||
import logging
|
||||
from datetime import datetime
|
||||
from threading import Thread, Event
|
||||
|
||||
import pytz
|
||||
import requests
|
||||
|
||||
from core.constants import HTTP_HEADERS
|
||||
from solarconditionsproviders.solar_conditions_provider import SolarConditionsProvider
|
||||
|
||||
|
||||
class HTTPSolarConditionsProvider(SolarConditionsProvider):
|
||||
"""Generic solar conditions provider for providers that request data via HTTP(S). Subclasses implement
|
||||
_http_response_to_solar_conditions() to parse the specific API response format."""
|
||||
|
||||
def __init__(self, provider_config, url, poll_interval):
|
||||
super().__init__(provider_config)
|
||||
self._url = url
|
||||
self._poll_interval = poll_interval
|
||||
self._thread = None
|
||||
self._stop_event = Event()
|
||||
|
||||
def start(self):
|
||||
logging.info(
|
||||
"Set up query of " + self.name + " solar conditions API every " + str(self._poll_interval) + " seconds.")
|
||||
self._thread = Thread(target=self._run, daemon=True)
|
||||
self._thread.start()
|
||||
|
||||
def stop(self):
|
||||
self._stop_event.set()
|
||||
|
||||
def _run(self):
|
||||
while True:
|
||||
self._poll()
|
||||
if self._stop_event.wait(timeout=self._poll_interval):
|
||||
break
|
||||
|
||||
def _poll(self):
|
||||
try:
|
||||
logging.debug("Polling " + self.name + " solar conditions API...")
|
||||
http_response = requests.get(self._url, headers=HTTP_HEADERS)
|
||||
new_data = self._http_response_to_solar_conditions(http_response)
|
||||
self.update_data(new_data)
|
||||
|
||||
self.status = "OK"
|
||||
self.last_update_time = datetime.now(pytz.UTC)
|
||||
logging.debug("Received data from " + self.name + " solar conditions API.")
|
||||
|
||||
except Exception:
|
||||
self.status = "Error"
|
||||
logging.exception("Exception in HTTP Solar Conditions Provider (" + self.name + ")")
|
||||
self._stop_event.wait(timeout=1)
|
||||
|
||||
def _http_response_to_solar_conditions(self, http_response):
|
||||
"""Convert an HTTP response into solar conditions data. Returns a dict mapping SolarConditions field
|
||||
names to their new values, or None if the response could not be parsed. Only the fields returned will
|
||||
be updated on the shared SolarConditions object; any fields not included will be left unchanged."""
|
||||
|
||||
raise NotImplementedError("Subclasses must implement this method")
|
||||
177
solarconditionsproviders/noaa3dayforecast.py
Normal file
177
solarconditionsproviders/noaa3dayforecast.py
Normal file
@@ -0,0 +1,177 @@
|
||||
import logging
|
||||
import re
|
||||
from datetime import datetime, timezone
|
||||
|
||||
from solarconditionsproviders.http_solar_conditions_provider import HTTPSolarConditionsProvider
|
||||
|
||||
POLL_INTERVAL = 10800 # Every 3 hours
|
||||
URL = "https://services.swpc.noaa.gov/text/3-day-forecast.txt"
|
||||
|
||||
|
||||
class NOAA3dayForecast(HTTPSolarConditionsProvider):
|
||||
"""Solar conditions provider using the NOAA 3-day forecast text file. Parses the NOAA forecast and populates
|
||||
corresponding fields in the solar conditions object.."""
|
||||
|
||||
def __init__(self, provider_config):
|
||||
super().__init__(provider_config, URL, POLL_INTERVAL)
|
||||
|
||||
@staticmethod
|
||||
def _parse_percentage_table(lines, section_header, year):
|
||||
"""Find and parse a forecast table using percentages, identified by section_header. This is common to the lookup
|
||||
of the solar storm and radio blackout forecast parsing."""
|
||||
start_idx = None
|
||||
for i, line in enumerate(lines):
|
||||
if section_header in line:
|
||||
start_idx = i
|
||||
break
|
||||
if start_idx is None:
|
||||
logging.warning(f"NOAA 3-day forecast: could not find '{section_header}' section")
|
||||
return None
|
||||
|
||||
# Find the date header line — the first line within the next few that contains month+day patterns
|
||||
date_header_idx = None
|
||||
for j in range(start_idx + 1, min(start_idx + 6, len(lines))):
|
||||
if re.search(r'[A-Za-z]{3}\s+\d{2}', lines[j]):
|
||||
date_header_idx = j
|
||||
break
|
||||
if date_header_idx is None:
|
||||
logging.warning(f"NOAA 3-day forecast: could not find date header after '{section_header}'")
|
||||
return None
|
||||
|
||||
date_matches = re.findall(r'([A-Za-z]{3})\s+(\d{2})', lines[date_header_idx])
|
||||
if not date_matches:
|
||||
logging.warning(f"NOAA 3-day forecast: no dates in header: {lines[date_header_idx]}")
|
||||
return None
|
||||
|
||||
column_timestamps = []
|
||||
for month_str, day_str in date_matches:
|
||||
try:
|
||||
dt = datetime.strptime(f"{day_str} {month_str} {year}", "%d %b %Y").replace(tzinfo=timezone.utc)
|
||||
column_timestamps.append(dt.timestamp())
|
||||
except ValueError:
|
||||
logging.warning(f"NOAA 3-day forecast: could not parse date: {month_str} {day_str} {year}")
|
||||
return None
|
||||
|
||||
# Parse data rows: each non-empty line should have a text label and percentage values
|
||||
result = {}
|
||||
for line in lines[date_header_idx + 1:]:
|
||||
line_stripped = line.strip()
|
||||
if not line_stripped:
|
||||
if result:
|
||||
break
|
||||
continue
|
||||
pct_matches = list(re.finditer(r'\b(\d+)%', line_stripped))
|
||||
if not pct_matches:
|
||||
if result:
|
||||
break
|
||||
continue
|
||||
# Row label is everything before the first percentage value
|
||||
row_label = line_stripped[:line_stripped.index(pct_matches[0].group())].strip()
|
||||
row_data = {}
|
||||
for j, match in enumerate(pct_matches):
|
||||
if j >= len(column_timestamps):
|
||||
break
|
||||
row_data[column_timestamps[j]] = int(match.group(1))
|
||||
if row_data:
|
||||
result[row_label] = row_data
|
||||
|
||||
return result if result else None
|
||||
|
||||
def _http_response_to_solar_conditions(self, http_response):
|
||||
if http_response.status_code != 200:
|
||||
logging.warning("NOAA K-index forecast API returned HTTP " + str(http_response.status_code))
|
||||
return None
|
||||
|
||||
lines = http_response.text.splitlines()
|
||||
|
||||
# Find the "NOAA Kp index breakdown" section header
|
||||
start_idx = None
|
||||
for i, line in enumerate(lines):
|
||||
if "NOAA Kp index breakdown" in line:
|
||||
start_idx = i
|
||||
break
|
||||
|
||||
if start_idx is None:
|
||||
logging.warning("NOAA K-index forecast: could not find 'NOAA Kp index breakdown' section")
|
||||
return None
|
||||
|
||||
# Extract the year from the header line, e.g. "NOAA Kp index breakdown Apr 2-Apr 4, 2026"
|
||||
header_line = lines[start_idx]
|
||||
year_match = re.search(r'\b(\d{4})\b', header_line)
|
||||
if not year_match:
|
||||
logging.warning("NOAA K-index forecast: could not extract year from: " + header_line)
|
||||
return None
|
||||
year = int(year_match.group(1))
|
||||
|
||||
# Parse the column date headers on the next line, e.g. " Apr 02 Apr 03 Apr 04"
|
||||
if start_idx + 1 >= len(lines):
|
||||
logging.warning("NOAA K-index forecast: missing date header line")
|
||||
return None
|
||||
|
||||
date_header_line = lines[start_idx + 2]
|
||||
date_matches = re.findall(r'([A-Za-z]{3})\s+(\d{2})', date_header_line)
|
||||
if not date_matches:
|
||||
logging.warning("NOAA K-index forecast: could not parse date headers from: " + date_header_line)
|
||||
return None
|
||||
|
||||
column_dates = []
|
||||
for month_str, day_str in date_matches:
|
||||
try:
|
||||
column_dates.append(datetime.strptime(f"{day_str} {month_str} {year}", "%d %b %Y").date())
|
||||
except ValueError:
|
||||
logging.warning(f"NOAA K-index forecast: could not parse date: {month_str} {day_str} {year}")
|
||||
return None
|
||||
|
||||
# Parse each data row, e.g. "00-03UT 2.00 3.00 2.00"
|
||||
k_index_forecast = {}
|
||||
for line in lines[start_idx + 3:]:
|
||||
time_match = re.match(r'^(\d{2})-(\d{2})UT\s+(.*)', line.strip())
|
||||
if not time_match:
|
||||
if k_index_forecast:
|
||||
break
|
||||
continue
|
||||
|
||||
start_hour = int(time_match.group(1))
|
||||
# Split on 2 or more spaces so that e.g. "5.67 (G2)" stays as one token per column
|
||||
raw_values = re.split(r' {2,}', time_match.group(3).strip())
|
||||
|
||||
for i, val in enumerate(raw_values):
|
||||
if i >= len(column_dates):
|
||||
break
|
||||
# Take only the leading numeric part, discarding any bracketed section
|
||||
try:
|
||||
kp = float(val.split()[0])
|
||||
except (ValueError, IndexError):
|
||||
continue
|
||||
|
||||
date = column_dates[i]
|
||||
start_dt = datetime(date.year, date.month, date.day, start_hour, 0, 0, tzinfo=timezone.utc)
|
||||
|
||||
# Key the data dict by start time
|
||||
key = start_dt.timestamp()
|
||||
k_index_forecast[key] = kp
|
||||
|
||||
if not k_index_forecast:
|
||||
logging.warning("NOAA K-index forecast: no data rows parsed")
|
||||
return None
|
||||
|
||||
# Parse Solar Radiation Storm Forecast (single row: "S1 or greater")
|
||||
solar_storm_forecast = None
|
||||
radiation_table = self._parse_percentage_table(lines, "Solar Radiation Storm Forecast", year)
|
||||
if radiation_table:
|
||||
solar_storm_forecast = radiation_table.get("S1 or greater")
|
||||
|
||||
# Parse Radio Blackout Forecast (two rows: "R1-R2" and "R3 or greater")
|
||||
blackout_forecast_r1r2 = None
|
||||
blackout_forecast_r3_or_greater = None
|
||||
blackout_table = self._parse_percentage_table(lines, "Radio Blackout Forecast", year)
|
||||
if blackout_table:
|
||||
blackout_forecast_r1r2 = blackout_table.get("R1-R2")
|
||||
blackout_forecast_r3_or_greater = blackout_table.get("R3 or greater")
|
||||
|
||||
return {
|
||||
"k_index_forecast": k_index_forecast,
|
||||
"solar_storm_forecast": solar_storm_forecast,
|
||||
"blackout_forecast_r1r2": blackout_forecast_r1r2,
|
||||
"blackout_forecast_r3_or_greater": blackout_forecast_r3_or_greater,
|
||||
}
|
||||
41
solarconditionsproviders/solar_conditions_provider.py
Normal file
41
solarconditionsproviders/solar_conditions_provider.py
Normal file
@@ -0,0 +1,41 @@
|
||||
from datetime import datetime
|
||||
|
||||
import pytz
|
||||
|
||||
|
||||
class SolarConditionsProvider:
|
||||
"""Generic solar conditions provider class. Subclasses of this query individual APIs for space weather and
|
||||
propagation data."""
|
||||
|
||||
def __init__(self, provider_config):
|
||||
"""Constructor"""
|
||||
|
||||
self.name = provider_config["name"]
|
||||
self.enabled = provider_config["enabled"]
|
||||
self.last_update_time = datetime.min.replace(tzinfo=pytz.UTC)
|
||||
self.status = "Not Started" if self.enabled else "Disabled"
|
||||
self._solar_conditions = None
|
||||
|
||||
def setup(self, solar_conditions):
|
||||
"""Set up the provider, giving it the solar conditions dict to update"""
|
||||
|
||||
self._solar_conditions = solar_conditions
|
||||
|
||||
def start(self):
|
||||
"""Start the provider. This should return immediately after spawning threads to access the remote resources"""
|
||||
|
||||
raise NotImplementedError("Subclasses must implement this method")
|
||||
|
||||
def stop(self):
|
||||
"""Stop any threads and prepare for application shutdown"""
|
||||
|
||||
raise NotImplementedError("Subclasses must implement this method")
|
||||
|
||||
def update_data(self, new_data):
|
||||
"""Update the solar conditions object with new data"""
|
||||
|
||||
if new_data:
|
||||
for key, value in new_data.items():
|
||||
if hasattr(self._solar_conditions, key):
|
||||
setattr(self._solar_conditions, key, value)
|
||||
self._solar_conditions.infer_descriptions()
|
||||
28
spothole.py
28
spothole.py
@@ -8,6 +8,7 @@ import sys
|
||||
from diskcache import Cache
|
||||
|
||||
from core.cleanup import CleanupTimer
|
||||
from data.solar_conditions import SolarConditions
|
||||
from core.config import config, WEB_SERVER_PORT, SERVER_OWNER_CALLSIGN
|
||||
from core.constants import SOFTWARE_NAME, SOFTWARE_VERSION
|
||||
from core.lookup_helper import lookup_helper
|
||||
@@ -17,10 +18,12 @@ from server.webserver import WebServer
|
||||
# Globals
|
||||
spots = Cache('cache/spots_cache')
|
||||
alerts = Cache('cache/alerts_cache')
|
||||
solar_conditions = SolarConditions()
|
||||
web_server = None
|
||||
status_data = {}
|
||||
spot_providers = []
|
||||
alert_providers = []
|
||||
solar_condition_providers = []
|
||||
cleanup_timer = None
|
||||
run = True
|
||||
|
||||
@@ -38,6 +41,9 @@ def shutdown(sig, frame):
|
||||
for ap in alert_providers:
|
||||
if ap.enabled:
|
||||
ap.stop()
|
||||
for scp in solar_condition_providers:
|
||||
if scp.enabled:
|
||||
scp.stop()
|
||||
cleanup_timer.stop()
|
||||
lookup_helper.stop()
|
||||
spots.close()
|
||||
@@ -61,6 +67,14 @@ def get_alert_provider_from_config(config_providers_entry):
|
||||
return provider_class(config_providers_entry)
|
||||
|
||||
|
||||
def get_solar_conditions_provider_from_config(config_providers_entry):
|
||||
"""Utility method to get a solar conditions provider based on the class specified in its config entry."""
|
||||
|
||||
module = importlib.import_module('solarconditionsproviders.' + config_providers_entry["class"].lower())
|
||||
provider_class = getattr(module, config_providers_entry["class"])
|
||||
return provider_class(config_providers_entry)
|
||||
|
||||
|
||||
# Main function
|
||||
if __name__ == '__main__':
|
||||
# Set up logging
|
||||
@@ -83,7 +97,8 @@ if __name__ == '__main__':
|
||||
lookup_helper.start()
|
||||
|
||||
# Set up web server
|
||||
web_server = WebServer(spots=spots, alerts=alerts, status_data=status_data, port=WEB_SERVER_PORT)
|
||||
web_server = WebServer(spots=spots, alerts=alerts, solar_conditions=solar_conditions, status_data=status_data,
|
||||
solar_condition_providers=solar_condition_providers, port=WEB_SERVER_PORT)
|
||||
|
||||
# Fetch, set up and start spot providers
|
||||
for entry in config["spot-providers"]:
|
||||
@@ -101,6 +116,14 @@ if __name__ == '__main__':
|
||||
if p.enabled:
|
||||
p.start()
|
||||
|
||||
# Fetch, 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:
|
||||
p.setup(solar_conditions=solar_conditions)
|
||||
if p.enabled:
|
||||
p.start()
|
||||
|
||||
# Set up timer to clear spot list of old data
|
||||
cleanup_timer = CleanupTimer(spots=spots, alerts=alerts, web_server=web_server, cleanup_interval=60)
|
||||
cleanup_timer.start()
|
||||
@@ -108,7 +131,8 @@ if __name__ == '__main__':
|
||||
# Set up status reporter
|
||||
status_reporter = StatusReporter(status_data=status_data, spots=spots, alerts=alerts, web_server=web_server,
|
||||
cleanup_timer=cleanup_timer, spot_providers=spot_providers,
|
||||
alert_providers=alert_providers, run_interval=5)
|
||||
alert_providers=alert_providers,
|
||||
solar_condition_providers=solar_condition_providers, run_interval=5)
|
||||
status_reporter.start()
|
||||
|
||||
logging.info("Startup complete.")
|
||||
|
||||
@@ -22,42 +22,43 @@ class ParksNPeaks(HTTPSpotProvider):
|
||||
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["actID"],
|
||||
dx_call=source_spot["actCallsign"].upper(),
|
||||
de_call=source_spot["actSpoter"].upper() if source_spot["actSpoter"] != "" else None,
|
||||
# typo exists in API
|
||||
freq=float(source_spot["actFreq"].replace(",", "")) * 1000000 if (
|
||||
source_spot["actFreq"] != "") else None,
|
||||
# Seen PNP spots with empty frequency, and with comma-separated thousands digits
|
||||
mode=source_spot["actMode"].upper(),
|
||||
comment=source_spot["actComments"],
|
||||
time=datetime.strptime(source_spot["actTime"], "%Y-%m-%d %H:%M:%S").replace(
|
||||
tzinfo=pytz.UTC).timestamp())
|
||||
if http_response and http_response != "":
|
||||
for source_spot in http_response.json():
|
||||
# Convert to our spot format
|
||||
spot = Spot(source=self.name,
|
||||
source_id=source_spot["actID"],
|
||||
dx_call=source_spot["actCallsign"].upper(),
|
||||
de_call=source_spot["actSpoter"].upper() if source_spot["actSpoter"] != "" else None,
|
||||
# typo exists in API
|
||||
freq=float(source_spot["actFreq"].replace(",", "")) * 1000000 if (
|
||||
source_spot["actFreq"] != "") else None,
|
||||
# Seen PNP spots with empty frequency, and with comma-separated thousands digits
|
||||
mode=source_spot["actMode"].upper(),
|
||||
comment=source_spot["actComments"],
|
||||
time=datetime.strptime(source_spot["actTime"], "%Y-%m-%d %H:%M:%S").replace(
|
||||
tzinfo=pytz.UTC).timestamp())
|
||||
|
||||
# Extract a de_call if it's in the comment but not in the "actSpoter" field
|
||||
m = re.search(r"\(de ([A-Za-z0-9]*)\)", spot.comment)
|
||||
if not spot.de_call and m:
|
||||
spot.de_call = m.group(1)
|
||||
# Extract a de_call if it's in the comment but not in the "actSpoter" field
|
||||
m = re.search(r"\(de ([A-Za-z0-9]*)\)", spot.comment)
|
||||
if not spot.de_call and m:
|
||||
spot.de_call = m.group(1)
|
||||
|
||||
# Record SIG information. Sometimes we get a "SIG" of "QRP", which we ignore as it's not a programme with a
|
||||
# defined set of references
|
||||
sig = source_spot["actClass"].upper()
|
||||
sig_ref = source_spot["actSiteID"]
|
||||
if sig and sig != "" and sig != "QRP" and sig_ref and sig_ref != "":
|
||||
spot.sig = sig
|
||||
spot.sig_refs = [SIGRef(id=source_spot["actSiteID"], sig=source_spot["actClass"].upper())]
|
||||
# Record SIG information. Sometimes we get a "SIG" of "QRP", which we ignore as it's not a programme with a
|
||||
# defined set of references
|
||||
sig = source_spot["actClass"].upper()
|
||||
sig_ref = source_spot["actSiteID"]
|
||||
if sig and sig != "" and sig != "QRP" and sig_ref and sig_ref != "":
|
||||
spot.sig = sig
|
||||
spot.sig_refs = [SIGRef(id=source_spot["actSiteID"], sig=source_spot["actClass"].upper())]
|
||||
|
||||
# Free text location is not present in all spots, so only add it if it's set
|
||||
if "actLocation" in source_spot and source_spot["actLocation"] != "":
|
||||
spot.sig_refs[0].name = source_spot["actLocation"]
|
||||
# Free text location is not present in all spots, so only add it if it's set
|
||||
if "actLocation" in source_spot and source_spot["actLocation"] != "":
|
||||
spot.sig_refs[0].name = source_spot["actLocation"]
|
||||
|
||||
# Log a warning for the developer if PnP gives us an unknown programme we've never seen before
|
||||
if sig not in ["POTA", "SOTA", "WWFF", "SIOTA", "ZLOTA", "KRMNPA"]:
|
||||
logging.warning("PNP spot found with sig " + sig + ", developer needs to add support for this!")
|
||||
# Log a warning for the developer if PnP gives us an unknown programme we've never seen before
|
||||
if sig not in ["POTA", "SOTA", "WWFF", "SIOTA", "ZLOTA", "KRMNPA"]:
|
||||
logging.warning("PNP spot found with sig " + sig + ", developer needs to add support for this!")
|
||||
|
||||
# Add new spot to the list
|
||||
new_spots.append(spot)
|
||||
# Add new spot to the list
|
||||
new_spots.append(spot)
|
||||
return new_spots
|
||||
|
||||
@@ -3,7 +3,7 @@ import re
|
||||
from datetime import datetime
|
||||
|
||||
import pytz
|
||||
from rss_parser import RSSParser
|
||||
from rss_parser import Parser
|
||||
|
||||
from data.sig_ref import SIGRef
|
||||
from data.spot import Spot
|
||||
@@ -23,7 +23,7 @@ class WOTA(HTTPSpotProvider):
|
||||
|
||||
def _http_response_to_spots(self, http_response):
|
||||
new_spots = []
|
||||
rss = RSSParser.parse(http_response.content.decode())
|
||||
rss = Parser.parse(http_response.content.decode())
|
||||
# Iterate through source data
|
||||
for source_spot in rss.channel.items:
|
||||
|
||||
|
||||
@@ -27,6 +27,7 @@
|
||||
<h4 class="mt-4">What data sources are supported?</h4>
|
||||
<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>, <a href="https://llota.app">LLOTA</a>, <a href="https://wwtota.com">WWTOTA</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>Spothole can retrieve solar and propagation condition data from <a href="https://www.hamqsl.com">HamQSL</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: 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), Lagos y Lagunas On the Air (LLOTA), Towers on the Air (WWTOTA), 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>
|
||||
@@ -61,12 +62,12 @@
|
||||
{% end %}
|
||||
<p>Spothole is open source, so you can audit <a href="https://git.ianrenton.com/ian/spothole">the code</a> if you like.</p>
|
||||
<h2 class="mt-4">Thanks</h2>
|
||||
<p>This project would not have been possible without those volunteers who have taken it upon themselves to run DX clusters, xOTA programmes, DXpedition lists, callsign lookup databases, and other online tools on which Spothole's data is based.</p>
|
||||
<p>This project would not have been possible without those volunteers who have taken it upon themselves to run DX clusters, xOTA programmes, DXpedition lists, callsign lookup databases, solar conditions and propagation modelling software, and other online tools on which Spothole's data is based. The vast majority of these are not profit-seeking and are made purely for the love of the hobby and to help others in the community. Spothole is standing on the shoulders of giants, who deserve a huge amount of thanks for all the work they put in.</p>
|
||||
<p>Spothole is also dependent on a number of Python libraries, in particular pyhamtools, and many JavaScript libraries, as well as the Font Awesome icon set and flag icons from the Noto Color Emoji set, and MIT-licenced GeoJSON files for CQ and ITU zones from HA8TKS.</p>
|
||||
<p>This software is dedicated to the memory of Tom G1PJB, SK, a friend and colleague who sadly passed away around the time I started writing it in Autumn 2025. I was looking forward to showing it to you when it was done.</p>
|
||||
</div>
|
||||
|
||||
<script src="/js/common.js?v=1774598773"></script>
|
||||
<script src="/js/common.js?v=1776849830"></script>
|
||||
<script>$(document).ready(function() { $("#nav-link-about").addClass("active"); }); <!-- highlight active page in nav --></script>
|
||||
|
||||
{% end %}
|
||||
@@ -69,8 +69,8 @@
|
||||
|
||||
</div>
|
||||
|
||||
<script src="/js/common.js?v=1774598773"></script>
|
||||
<script src="/js/add-spot.js?v=1774598773"></script>
|
||||
<script src="/js/common.js?v=1776849830"></script>
|
||||
<script src="/js/add-spot.js?v=1776849830"></script>
|
||||
<script>$(document).ready(function() { $("#nav-link-add-spot").addClass("active"); }); <!-- highlight active page in nav --></script>
|
||||
|
||||
{% end %}
|
||||
@@ -33,7 +33,7 @@
|
||||
<div id="display-area" class="appearing-panel card mb-3">
|
||||
{% module Template("widgets/display-area-header.html", web_ui_options=web_ui_options) %}
|
||||
<div class="card-body">
|
||||
<div id="display-container" class="row row-cols-1 row-cols-md-3 g-4">
|
||||
<div id="display-container" class="row row-cols-1 row-cols-md-4 g-4">
|
||||
<div class="col">
|
||||
{% module Template("cards/time-zone.html", web_ui_options=web_ui_options) %}
|
||||
</div>
|
||||
@@ -51,13 +51,13 @@
|
||||
</div>
|
||||
|
||||
<div id="table-container">
|
||||
<table id="table" class="table"><thead><tr class="table-primary"></tr></thead><tbody></tbody></table>
|
||||
<table id="table" class="table"><thead><tr></tr></thead><tbody></tbody></table>
|
||||
</div>
|
||||
|
||||
</div>
|
||||
|
||||
<script src="/js/common.js?v=1774598773"></script>
|
||||
<script src="/js/alerts.js?v=1774598773"></script>
|
||||
<script src="/js/common.js?v=1776849830"></script>
|
||||
<script src="/js/alerts.js?v=1776849830"></script>
|
||||
<script>$(document).ready(function() { $("#nav-link-alerts").addClass("active"); }); <!-- highlight active page in nav --></script>
|
||||
|
||||
{% end %}
|
||||
@@ -62,9 +62,9 @@
|
||||
<script>
|
||||
let spotProvidersEnabledByDefault = {% raw json_encode(web_ui_options["spot-providers-enabled-by-default"]) %};
|
||||
</script>
|
||||
<script src="/js/common.js?v=1774598773"></script>
|
||||
<script src="/js/spotsbandsandmap.js?v=1774598773"></script>
|
||||
<script src="/js/bands.js?v=1774598773"></script>
|
||||
<script src="/js/common.js?v=1776849830"></script>
|
||||
<script src="/js/spotsbandsandmap.js?v=1776849830"></script>
|
||||
<script src="/js/bands.js?v=1776849830"></script>
|
||||
<script>$(document).ready(function() { $("#nav-link-bands").addClass("active"); }); <!-- highlight active page in nav --></script>
|
||||
|
||||
{% end %}
|
||||
@@ -24,7 +24,7 @@
|
||||
|
||||
<title>Spothole</title>
|
||||
|
||||
<link rel="stylesheet" href="/css/style.css" type="text/css">
|
||||
<link rel="stylesheet" href="/css/style.css?v=1776849830" 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" />
|
||||
@@ -46,10 +46,9 @@
|
||||
crossorigin="anonymous"></script>
|
||||
<script src="https://cdn.jsdelivr.net/npm/tinycolor2@1.6.0/cjs/tinycolor.min.js"></script>
|
||||
|
||||
<script src="https://misc.ianrenton.com/jsutils/utils.js?v=1774598773"></script>
|
||||
<script src="https://misc.ianrenton.com/jsutils/storage.js?v=1774598773"></script>
|
||||
<script src="https://misc.ianrenton.com/jsutils/ui-ham.js?v=1774598773"></script>
|
||||
<script src="https://misc.ianrenton.com/jsutils/geo.js?v=1774598773"></script>
|
||||
<script src="https://misc.ianrenton.com/jsutils/utils.js?v=1776849830"></script>
|
||||
<script src="https://misc.ianrenton.com/jsutils/ui-ham.js?v=1776849830"></script>
|
||||
<script src="https://misc.ianrenton.com/jsutils/geo.js?v=1776849830"></script>
|
||||
|
||||
</head>
|
||||
<body>
|
||||
@@ -67,9 +66,12 @@
|
||||
<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>
|
||||
<li class="nav-item ms-4"><a href="/bands" class="nav-link" id="nav-link-bands"><i class="fa-solid fa-ruler-vertical"></i> Bands</a></li>
|
||||
<li class="nav-item ms-4"><a href="/alerts" class="nav-link" id="nav-link-alerts"><i class="fa-solid fa-bell"></i> Alerts</a></li>
|
||||
<li class="nav-item ms-4"><a href="/alerts" class="nav-link" id="nav-link-alerts"><i class="fa-solid fa-clock"></i> Upcoming</a></li>
|
||||
{% if allow_spotting %}
|
||||
<li class="nav-item ms-4"><a href="/add-spot" class="nav-link" id="nav-link-add-spot"><i class="fa-solid fa-comment"></i> Add Spot</a></li>
|
||||
{% end %}
|
||||
{% if has_hamqsl or has_noaa_forecast %}
|
||||
<li class="nav-item ms-4"><a href="/conditions" class="nav-link" id="nav-link-conditions"><i class="fa-solid fa-sun"></i> Conditions</a></li>
|
||||
{% end %}
|
||||
<li class="nav-item ms-4"><a href="/status" class="nav-link" id="nav-link-status"><i class="fa-solid fa-chart-simple"></i> Status</a></li>
|
||||
<li class="nav-item ms-4"><a href="/about" class="nav-link" id="nav-link-about"><i class="fa-solid fa-circle-info"></i> About</a></li>
|
||||
|
||||
11
templates/cards/audio.html
Normal file
11
templates/cards/audio.html
Normal file
@@ -0,0 +1,11 @@
|
||||
<div class="card">
|
||||
<div class="card-body">
|
||||
<h5 class="card-title mb-3">Audio</h5>
|
||||
<div class="form-group">
|
||||
<div class="form-check form-check-inline">
|
||||
<input class="form-check-input storeable-checkbox" type="checkbox" id="pingOnNewSpots" value="pingOnNewSpots" oninput="saveSettings();">
|
||||
<label class="form-check-label" for="pingOnNewSpots">Ping on new spots</label>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -1,6 +1,6 @@
|
||||
<div class="card">
|
||||
<div class="card-body">
|
||||
<h5 class="card-title">Bands</h5>
|
||||
<p id="band-options" class="card-text spothole-card-text"></p>
|
||||
<div id="band-options" class="card-text spothole-card-text"></div>
|
||||
</div>
|
||||
</div>
|
||||
27
templates/cards/basemap.html
Normal file
27
templates/cards/basemap.html
Normal file
@@ -0,0 +1,27 @@
|
||||
<div class="card">
|
||||
<div class="card-body">
|
||||
<h5 class="card-title">Map Style</h5>
|
||||
<p class="card-text spothole-card-text">
|
||||
<label for="basemap" class="form-label">Basemap</label>
|
||||
<select id="basemap" class="storeable-select form-select" oninput="displayUpdated();">
|
||||
<option value="OpenStreetMap.Mapnik" selected>OpenStreetMap Mapnik</option>
|
||||
<option value="OpenStreetMap.Mapnik.Dark">OpenStreetMap Mapnik (Dark)</option>
|
||||
<option value="Esri.NatGeoWorldMap">ESRI NatGeo World Map</option>
|
||||
<option value="Esri.WorldTopoMap">ESRI World Topo Map</option>
|
||||
<option value="Esri.WorldShadedRelief">ESRI World Shaded Relief</option>
|
||||
<option value="Esri.WorldImagery">ESRI World Imagery</option>
|
||||
<option value="CartoDB.Voyager">CartoDB Voyager</option>
|
||||
<option value="CartoDB.DarkMatter">CartoDB DarkMatter</option>
|
||||
</select>
|
||||
</p>
|
||||
<p class="card-text spothole-card-text">
|
||||
<label for="basemapOpacity" class="form-label">Opacity</label>
|
||||
<select id="basemapOpacity" class="storeable-select form-select" oninput="displayUpdated();">
|
||||
<option value="1">100%</option>
|
||||
<option value="0.75">75%</option>
|
||||
<option value="0.5">50%</option>
|
||||
<option value="0.25">25%</option>
|
||||
</select>
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
@@ -1,6 +1,6 @@
|
||||
<div class="card">
|
||||
<div class="card-body">
|
||||
<h5 class="card-title">DE Continent</h5>
|
||||
<p id="de-continent-options" class="card-text spothole-card-text"></p>
|
||||
<div id="de-continent-options" class="card-text spothole-card-text"></div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -2,7 +2,7 @@
|
||||
<div class="card-body">
|
||||
<h5 class="card-title">Duration Limit <i class='fa-solid fa-circle-question' title='Some users create long-duration alerts for the period they will be generally in and around xOTA references, when they are not indending to be on the air most of the time. Use this control to restrict the maximum duration of spots that the software will display, and exclude any with a long duration, to avoid these filling up the list. By default, we allow DXpeditions to be displayed even if they are longer than this limit, because on a DXpedition the operators typically ARE on the air most of the time.'></i></h5>
|
||||
<p class="card-text spothole-card-text">
|
||||
Hide any alerts lasting more than:<br/>
|
||||
<label for="max-duration" class="form-label">Hide any alerts lasting more than</label>
|
||||
<select id="max-duration" class="storeable-select form-select" onclick="filtersUpdated();" style="width: 8em; display: inline-block;">
|
||||
<option value="10800">3 hours</option>
|
||||
<option value="43200">12 hours</option>
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
<div class="card">
|
||||
<div class="card-body">
|
||||
<h5 class="card-title">DX Continent</h5>
|
||||
<p id="dx-continent-options" class="card-text spothole-card-text"></p>
|
||||
<div id="dx-continent-options" class="card-text spothole-card-text"></div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -1,11 +1,41 @@
|
||||
<div class="card">
|
||||
<div class="card-body">
|
||||
<h5 class="card-title">Map Features</h5>
|
||||
<h5 class="card-title mb-3">Map Features</h5>
|
||||
<div class="form-group">
|
||||
<div class="form-check form-check-inline">
|
||||
<input class="form-check-input storeable-checkbox" type="checkbox" id="mapShowGeodesics" value="mapShowGeodesics" oninput="displayUpdated();">
|
||||
<label class="form-check-label" for="mapShowGeodesics">Geodesic Lines</label>
|
||||
</div>
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<div class="form-check form-check-inline">
|
||||
<input class="form-check-input storeable-checkbox" type="checkbox" id="showTerminator" oninput="displayUpdated();" checked>
|
||||
<label class="form-check-label" for="showTerminator">Terminator / Greyline</label>
|
||||
</div>
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<div class="form-check form-check-inline">
|
||||
<input class="form-check-input storeable-checkbox" type="checkbox" id="showMaidenheadGrid" oninput="displayUpdated();">
|
||||
<label class="form-check-label" for="showMaidenheadGrid">Maidenhead Grid</label>
|
||||
</div>
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<div class="form-check form-check-inline">
|
||||
<input class="form-check-input storeable-checkbox" type="checkbox" id="showCQZones" oninput="displayUpdated();">
|
||||
<label class="form-check-label" for="showCQZones">CQ Zones</label>
|
||||
</div>
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<div class="form-check form-check-inline">
|
||||
<input class="form-check-input storeable-checkbox" type="checkbox" id="showITUZones" oninput="displayUpdated();">
|
||||
<label class="form-check-label" for="showITUZones">ITU Zones</label>
|
||||
</div>
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<div class="form-check form-check-inline">
|
||||
<input class="form-check-input storeable-checkbox" type="checkbox" id="showWABWAIGrid" oninput="displayUpdated();">
|
||||
<label class="form-check-label" for="showWABWAIGrid">WAB/WAI Grid</label>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -1,6 +1,6 @@
|
||||
<div class="card">
|
||||
<div class="card-body">
|
||||
<h5 class="card-title">Modes</h5>
|
||||
<p id="mode-options" class="card-text spothole-card-text"></p>
|
||||
<div id="mode-options" class="card-text spothole-card-text"></div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -2,7 +2,7 @@
|
||||
<div class="card-body">
|
||||
<h5 class="card-title">Number of Alerts</h5>
|
||||
<p class="card-text spothole-card-text">Show up to
|
||||
<select id="alerts-to-fetch" class="storeable-select form-select ms-2" oninput="filtersUpdated();" style="width: 5em;display: inline-block;">
|
||||
<select id="alerts-to-fetch" class="storeable-select form-select ms-2 me-2" oninput="filtersUpdated();" style="width: 5em;display: inline-block;">
|
||||
{% for c in web_ui_options["alert-count"] %}
|
||||
<option value="{{c}}" {% if web_ui_options["alert-count-default"] == c %}selected{% end %}>{{c}}</option>
|
||||
{% end %}
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
<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 id="sig-options" class="card-text spothole-card-text"></div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -1,6 +1,6 @@
|
||||
<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 id="source-options" class="card-text spothole-card-text"></div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -1,35 +1,35 @@
|
||||
<div class="card">
|
||||
<div class="card-body">
|
||||
<h5 class="card-title">Table Columns</h5>
|
||||
<div class="form-group">
|
||||
<div class="form-check form-check-inline">
|
||||
<input class="form-check-input storeable-checkbox" type="checkbox" id="tableShowStartTime" value="tableShowStartTime" oninput="columnsUpdated();" checked>
|
||||
<label class="form-check-label" for="tableShowStartTime">Start Time</label>
|
||||
</div>
|
||||
<div class="form-check form-check-inline">
|
||||
<input class="form-check-input storeable-checkbox" type="checkbox" id="tableShowEndTime" value="tableShowEndTime" oninput="columnsUpdated();" checked>
|
||||
<label class="form-check-label" for="tableShowEndTime">End Time</label>
|
||||
</div>
|
||||
<div class="form-check form-check-inline">
|
||||
<input class="form-check-input storeable-checkbox" type="checkbox" id="tableShowDX" value="tableShowDX" oninput="columnsUpdated();" checked>
|
||||
<label class="form-check-label" for="tableShowDX">DX</label>
|
||||
</div>
|
||||
<div class="form-check form-check-inline">
|
||||
<input class="form-check-input storeable-checkbox" type="checkbox" id="tableShowFreqsModes" value="tableShowFreqsModes" oninput="columnsUpdated();" checked>
|
||||
<label class="form-check-label" for="tableShowFreqsModes">Frequencies & Modes</label>
|
||||
</div>
|
||||
<div class="form-check form-check-inline">
|
||||
<input class="form-check-input storeable-checkbox" type="checkbox" id="tableShowComment" value="tableShowComment" oninput="columnsUpdated();" checked>
|
||||
<label class="form-check-label" for="tableShowComment">Comment</label>
|
||||
</div>
|
||||
<div class="form-check form-check-inline">
|
||||
<input class="form-check-input storeable-checkbox" type="checkbox" id="tableShowSource" value="tableShowSource" oninput="columnsUpdated();" checked>
|
||||
<label class="form-check-label" for="tableShowSource">Source</label>
|
||||
</div>
|
||||
<div class="form-check form-check-inline">
|
||||
<input class="form-check-input storeable-checkbox" type="checkbox" id="tableShowRef" value="tableShowRef" oninput="columnsUpdated();" checked>
|
||||
<label class="form-check-label" for="tableShowRef">Ref.</label>
|
||||
</div>
|
||||
<div class="row row-cols-2 g-1">
|
||||
<div class="col"><div class="form-check">
|
||||
<input class="form-check-input storeable-checkbox" type="checkbox" id="tableShowStartTime" value="tableShowStartTime" oninput="columnsUpdated();" checked>
|
||||
<label class="form-check-label" for="tableShowStartTime">Start Time</label>
|
||||
</div></div>
|
||||
<div class="col"><div class="form-check">
|
||||
<input class="form-check-input storeable-checkbox" type="checkbox" id="tableShowEndTime" value="tableShowEndTime" oninput="columnsUpdated();" checked>
|
||||
<label class="form-check-label" for="tableShowEndTime">End Time</label>
|
||||
</div></div>
|
||||
<div class="col"><div class="form-check">
|
||||
<input class="form-check-input storeable-checkbox" type="checkbox" id="tableShowDX" value="tableShowDX" oninput="columnsUpdated();" checked>
|
||||
<label class="form-check-label" for="tableShowDX">DX</label>
|
||||
</div></div>
|
||||
<div class="col"><div class="form-check">
|
||||
<input class="form-check-input storeable-checkbox" type="checkbox" id="tableShowFreqsModes" value="tableShowFreqsModes" oninput="columnsUpdated();" checked>
|
||||
<label class="form-check-label" for="tableShowFreqsModes">Freq & Mode</label>
|
||||
</div></div>
|
||||
<div class="col"><div class="form-check">
|
||||
<input class="form-check-input storeable-checkbox" type="checkbox" id="tableShowComment" value="tableShowComment" oninput="columnsUpdated();" checked>
|
||||
<label class="form-check-label" for="tableShowComment">Comment</label>
|
||||
</div></div>
|
||||
<div class="col"><div class="form-check">
|
||||
<input class="form-check-input storeable-checkbox" type="checkbox" id="tableShowSource" value="tableShowSource" oninput="columnsUpdated();" checked>
|
||||
<label class="form-check-label" for="tableShowSource">Source</label>
|
||||
</div></div>
|
||||
<div class="col"><div class="form-check">
|
||||
<input class="form-check-input storeable-checkbox" type="checkbox" id="tableShowRef" value="tableShowRef" oninput="columnsUpdated();" checked>
|
||||
<label class="form-check-label" for="tableShowRef">Ref.</label>
|
||||
</div></div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -1,47 +1,47 @@
|
||||
<div class="card">
|
||||
<div class="card-body">
|
||||
<h5 class="card-title">Table Columns</h5>
|
||||
<div class="form-group">
|
||||
<div class="form-check form-check-inline">
|
||||
<input class="form-check-input storeable-checkbox" type="checkbox" id="tableShowTime" value="tableShowTime" oninput="columnsUpdated();" checked>
|
||||
<label class="form-check-label" for="tableShowTime">Time</label>
|
||||
</div>
|
||||
<div class="form-check form-check-inline">
|
||||
<input class="form-check-input storeable-checkbox" type="checkbox" id="tableShowDX" value="tableShowDX" oninput="columnsUpdated();" checked>
|
||||
<label class="form-check-label" for="tableShowDX">DX</label>
|
||||
</div>
|
||||
<div class="form-check form-check-inline">
|
||||
<input class="form-check-input storeable-checkbox" type="checkbox" id="tableShowFreq" value="tableShowFreq" oninput="columnsUpdated();" checked>
|
||||
<label class="form-check-label" for="tableShowFreq">Frequency</label>
|
||||
</div>
|
||||
<div class="form-check form-check-inline">
|
||||
<input class="form-check-input storeable-checkbox" type="checkbox" id="tableShowMode" value="tableShowMode" oninput="columnsUpdated();" checked>
|
||||
<label class="form-check-label" for="tableShowMode">Mode</label>
|
||||
</div>
|
||||
<div class="form-check form-check-inline">
|
||||
<input class="form-check-input storeable-checkbox" type="checkbox" id="tableShowComment" value="tableShowComment" oninput="columnsUpdated();" checked>
|
||||
<label class="form-check-label" for="tableShowComment">Comment</label>
|
||||
</div>
|
||||
<div class="form-check form-check-inline">
|
||||
<input class="form-check-input storeable-checkbox" type="checkbox" id="tableShowBearing" value="tableShowBearing" oninput="columnsUpdated();">
|
||||
<label class="form-check-label" for="tableShowBearing">Bearing</label>
|
||||
</div>
|
||||
<div class="form-check form-check-inline">
|
||||
<input class="form-check-input storeable-checkbox" type="checkbox" id="tableShowType" value="tableShowType" oninput="columnsUpdated();" checked>
|
||||
<label class="form-check-label" for="tableShowType">Type</label>
|
||||
</div>
|
||||
<div class="form-check form-check-inline">
|
||||
<input class="form-check-input storeable-checkbox" type="checkbox" id="tableShowRef" value="tableShowRef" oninput="columnsUpdated();" checked>
|
||||
<label class="form-check-label" for="tableShowRef">Ref.</label>
|
||||
</div>
|
||||
<div class="form-check form-check-inline">
|
||||
<input class="form-check-input storeable-checkbox" type="checkbox" id="tableShowDE" value="tableShowDE" oninput="columnsUpdated();" checked>
|
||||
<label class="form-check-label" for="tableShowDE">DE</label>
|
||||
</div>
|
||||
<div class="form-check form-check-inline">
|
||||
<input class="form-check-input storeable-checkbox" type="checkbox" id="tableShowWorkedCheckbox" value="tableShowWorkedCheckbox" oninput="columnsUpdated();" checked>
|
||||
<label class="form-check-label" for="tableShowWorkedCheckbox">Worked?</label>
|
||||
</div>
|
||||
<div class="row row-cols-2 g-1">
|
||||
<div class="col"><div class="form-check">
|
||||
<input class="form-check-input storeable-checkbox" type="checkbox" id="tableShowTime" value="tableShowTime" oninput="columnsUpdated();" checked>
|
||||
<label class="form-check-label" for="tableShowTime">Time</label>
|
||||
</div></div>
|
||||
<div class="col"><div class="form-check">
|
||||
<input class="form-check-input storeable-checkbox" type="checkbox" id="tableShowDX" value="tableShowDX" oninput="columnsUpdated();" checked>
|
||||
<label class="form-check-label" for="tableShowDX">DX</label>
|
||||
</div></div>
|
||||
<div class="col"><div class="form-check">
|
||||
<input class="form-check-input storeable-checkbox" type="checkbox" id="tableShowFreq" value="tableShowFreq" oninput="columnsUpdated();" checked>
|
||||
<label class="form-check-label" for="tableShowFreq">Frequency</label>
|
||||
</div></div>
|
||||
<div class="col"><div class="form-check">
|
||||
<input class="form-check-input storeable-checkbox" type="checkbox" id="tableShowMode" value="tableShowMode" oninput="columnsUpdated();" checked>
|
||||
<label class="form-check-label" for="tableShowMode">Mode</label>
|
||||
</div></div>
|
||||
<div class="col"><div class="form-check">
|
||||
<input class="form-check-input storeable-checkbox" type="checkbox" id="tableShowComment" value="tableShowComment" oninput="columnsUpdated();" checked>
|
||||
<label class="form-check-label" for="tableShowComment">Comment</label>
|
||||
</div></div>
|
||||
<div class="col"><div class="form-check">
|
||||
<input class="form-check-input storeable-checkbox" type="checkbox" id="tableShowBearing" value="tableShowBearing" oninput="columnsUpdated();">
|
||||
<label class="form-check-label" for="tableShowBearing">Bearing</label>
|
||||
</div></div>
|
||||
<div class="col"><div class="form-check">
|
||||
<input class="form-check-input storeable-checkbox" type="checkbox" id="tableShowType" value="tableShowType" oninput="columnsUpdated();" checked>
|
||||
<label class="form-check-label" for="tableShowType">Type</label>
|
||||
</div></div>
|
||||
<div class="col"><div class="form-check">
|
||||
<input class="form-check-input storeable-checkbox" type="checkbox" id="tableShowRef" value="tableShowRef" oninput="columnsUpdated();" checked>
|
||||
<label class="form-check-label" for="tableShowRef">Ref.</label>
|
||||
</div></div>
|
||||
<div class="col"><div class="form-check">
|
||||
<input class="form-check-input storeable-checkbox" type="checkbox" id="tableShowDE" value="tableShowDE" oninput="columnsUpdated();" checked>
|
||||
<label class="form-check-label" for="tableShowDE">DE</label>
|
||||
</div></div>
|
||||
<div class="col"><div class="form-check">
|
||||
<input class="form-check-input storeable-checkbox" type="checkbox" id="tableShowWorkedCheckbox" value="tableShowWorkedCheckbox" oninput="columnsUpdated();" checked>
|
||||
<label class="form-check-label" for="tableShowWorkedCheckbox">Worked?</label>
|
||||
</div></div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
239
templates/conditions.html
Normal file
239
templates/conditions.html
Normal file
@@ -0,0 +1,239 @@
|
||||
{% extends "base.html" %}
|
||||
{% block content %}
|
||||
|
||||
{% if has_hamqsl %}
|
||||
<div class="card mt-5">
|
||||
<div class="card-header">
|
||||
Propagation Conditions
|
||||
</div>
|
||||
<div class="card-body">
|
||||
<div class="row row-cols-1 row-cols-md-2 g-3">
|
||||
<div class="col mt-3 px-3">
|
||||
<h5>HF</h5>
|
||||
<table class="table table-sm mt-2">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Band</th>
|
||||
<th>Day</th>
|
||||
<th>Night</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<tr>
|
||||
<td>80-40m</td>
|
||||
<td id="hf-conditions-80m-40m-day"></td>
|
||||
<td id="hf-conditions-80m-40m-night"></td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td>30-20m</td>
|
||||
<td id="hf-conditions-30m-20m-day"></td>
|
||||
<td id="hf-conditions-30m-20m-night"></td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td>17-15m</td>
|
||||
<td id="hf-conditions-17m-15m-day"></td>
|
||||
<td id="hf-conditions-17m-15m-night"></td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td>12-10m</td>
|
||||
<td id="hf-conditions-12m-10m-day"></td>
|
||||
<td id="hf-conditions-12m-10m-night"></td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
<div class="col mt-3 px-3">
|
||||
<h5>VHF</h5>
|
||||
<table class="table table-sm mt-2">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Propagation Mode</th>
|
||||
<th>Condition</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<tr>
|
||||
<td>Sporadic-E 6m (Europe)</td>
|
||||
<td id="vhf-conditions-es_6m_europe"></td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td>Sporadic-E 4m (Europe)</td>
|
||||
<td id="vhf-conditions-es_4m_europe"></td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td>Sporadic-E 2m (Europe)</td>
|
||||
<td id="vhf-conditions-es_2m_europe"></td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td>Sporadic-E 2m (North America)</td>
|
||||
<td id="vhf-conditions-es_2m_na"></td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td>Aurora (Northern Hemisphere)</td>
|
||||
<td id="vhf-conditions-vhf_aurora_northern_hemi"></td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td>Aurora Minimum Latitude</td>
|
||||
<td id="vhf-conditions-aurora-lat"></td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
<div class="form-text mt-3">Data from <a href="https://hamqsl.com">HamQSL.com</a>.</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="card mt-5">
|
||||
<div class="card-header">
|
||||
Solar Weather
|
||||
</div>
|
||||
<div class="card-body px-3">
|
||||
<div class="row border-bottom align-items-start me-0">
|
||||
<div class="col-12 col-md-2 py-2 fw-bold">Solar Flux</div>
|
||||
<div id="sw-solar-flux-vals" class="col-12 col-md-3 py-2">
|
||||
<span class="me-3">SFI: <strong id="sw-sfi"></strong></span>
|
||||
<span>Sunspots: <strong id="sw-sunspots"></strong></span>
|
||||
</div>
|
||||
<div id="sw-solar-flux-desc" class="col-12 col-md-7 py-2"></div>
|
||||
</div>
|
||||
<div class="row border-bottom align-items-start me-0">
|
||||
<div class="col-12 col-md-2 py-2 fw-bold">Geomagnetic</div>
|
||||
<div id="sw-geomag-vals" class="col-12 col-md-3 py-2">
|
||||
<span class="me-3">K: <strong id="sw-k-index"></strong></span>
|
||||
<span class="me-3">A: <strong id="sw-a-index"></strong></span>
|
||||
<span class="me-3"><strong>G</strong><strong id="sw-geomag-storm-scale"></strong></span>
|
||||
<span>Noise: <strong id="sw-geomag-noise"></strong></span>
|
||||
</div>
|
||||
<div id="sw-geomag-desc" class="col-12 col-md-7 py-2">
|
||||
<span id="sw-geomag-field"></span>. <span id="sw-geomag-storm-desc"></span>
|
||||
</div>
|
||||
</div>
|
||||
<div class="row border-bottom align-items-start me-0">
|
||||
<div class="col-12 col-md-2 py-2 fw-bold">X-ray Flux</div>
|
||||
<div id="sw-xray-vals" class="col-12 col-md-3 py-2">
|
||||
<span class="me-3"><strong id="sw-xray"></strong></span>
|
||||
<span class="me-3"><strong>R</strong><strong id="sw-radio-blackout-scale"></strong></span></div>
|
||||
<div id="sw-xray-desc" class="col-12 col-md-7 py-2"></div>
|
||||
</div>
|
||||
<div class="row border-bottom align-items-start me-0">
|
||||
<div class="col-12 col-md-2 py-2 fw-bold">Proton Flux</div>
|
||||
<div id="sw-proton-vals" class="col-12 col-md-3 py-2">
|
||||
<span class="me-3"><strong id="sw-proton-flux"></strong> pfu</span>
|
||||
<span class="me-3"><strong>S</strong><strong id="sw-solar-storm-scale"></strong></span>
|
||||
</div>
|
||||
<div id="sw-proton-desc" class="col-12 col-md-7 py-2"></div>
|
||||
</div>
|
||||
<div class="row border-bottom align-items-start me-0">
|
||||
<div class="col-12 col-md-2 fw-bold py-2">Electron Flux</div>
|
||||
<div id="sw-electron-vals" class="col-12 col-md-3 py-2"><strong id="sw-electron-flux"></strong> efu</div>
|
||||
<div id="sw-electron-desc" class="col-12 col-md-7 py-2"></div>
|
||||
</div>
|
||||
<div class="form-text mt-3">Data from <a href="https://hamqsl.com">HamQSL.com</a>.</div>
|
||||
</div>
|
||||
</div>
|
||||
{% end %}
|
||||
|
||||
{% if has_noaa_forecast %}
|
||||
<div class="card mt-5">
|
||||
<div class="card-header">
|
||||
Forecast
|
||||
</div>
|
||||
<div class="card-body">
|
||||
<div class="row mb-4">
|
||||
<div class="col px-3">
|
||||
<h5>K-index Forecast</h5>
|
||||
<canvas id="forecast-kp-chart" class="mt-3 mb-3"></canvas>
|
||||
</div>
|
||||
</div>
|
||||
<div class="row row-cols-1 row-cols-md-2 g-3">
|
||||
<div class="col mt-3 px-3">
|
||||
<h5>Solar Storm Forecast</h5>
|
||||
<table id="forecast-solar-storm-table" class="table table-sm mt-2">
|
||||
<thead>
|
||||
<tr id="forecast-solar-storm-head"></tr>
|
||||
</thead>
|
||||
<tbody id="forecast-solar-storm-tbody"></tbody>
|
||||
</table>
|
||||
</div>
|
||||
<div class="col mt-3 px-3">
|
||||
<h5>Radio Blackout Forecast</h5>
|
||||
<table id="forecast-blackout-table" class="table table-sm mt-2">
|
||||
<thead>
|
||||
<tr id="forecast-blackout-head"></tr>
|
||||
</thead>
|
||||
<tbody id="forecast-blackout-tbody"></tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
<div class="form-text mt-3">Data from <a href="https://www.swpc.noaa.gov/">NOAA Space Weather Prediction
|
||||
Center</a>.
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{% end %}
|
||||
|
||||
<div class="card mt-5">
|
||||
<div class="card-header">
|
||||
DX Opportunities
|
||||
</div>
|
||||
<div class="card-body">
|
||||
<div class="mb-3">
|
||||
<label for="dxstats-de-continent" class="form-label">Your continent:</label>
|
||||
<select id="dxstats-de-continent" class="form-select storeable-select d-inline-block ms-2"
|
||||
style="width: auto;" oninput="dxStatsContientChanged();">
|
||||
<option value="EU">Europe</option>
|
||||
<option value="NA">North America</option>
|
||||
<option value="SA">South America</option>
|
||||
<option value="AS">Asia</option>
|
||||
<option value="AF">Africa</option>
|
||||
<option value="OC">Oceania</option>
|
||||
<option value="AN">Antarctica</option>
|
||||
</select>
|
||||
</div>
|
||||
<div class="table-responsive">
|
||||
<table class="table table-sm table-bordered mb-0">
|
||||
<thead>
|
||||
<tr>
|
||||
<th></th>
|
||||
<th>160m</th>
|
||||
<th>80m</th>
|
||||
<th>60m</th>
|
||||
<th>40m</th>
|
||||
<th>30m</th>
|
||||
<th>20m</th>
|
||||
<th>17m</th>
|
||||
<th>15m</th>
|
||||
<th>12m</th>
|
||||
<th>10m</th>
|
||||
<th>6m</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{% for continent in ["EU", "NA", "SA", "AS", "AF", "OC", "AN"] %}
|
||||
<tr>
|
||||
<td class="fw-bold">{{ continent }}</td>
|
||||
{% for band in ["160m", "80m", "60m", "40m", "30m", "20m", "17m", "15m", "12m", "10m", "6m"] %}
|
||||
<td id="dxstats-{{ continent }}-{{ band }}"></td>
|
||||
{% end %}
|
||||
</tr>
|
||||
{% end %}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
<div class="form-text mt-2">This table shows the number of spots in the past hour received in your continent,
|
||||
where the DX continent and band are as shown in the table. Bands with high numbers of spots are likely to be
|
||||
the best ones for making contact with the continent you want right now. Bear in mind that some bands and
|
||||
some continents are inherently much rarer than others.
|
||||
</div>
|
||||
</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=1776849830"></script>
|
||||
<script src="/js/conditions.js?v=1776849830"></script>
|
||||
<script>$(document).ready(function () {
|
||||
$("#nav-link-conditions").addClass("active");
|
||||
}); <!-- highlight active page in nav --></script>
|
||||
|
||||
{% end %}
|
||||
@@ -47,6 +47,9 @@
|
||||
<div class="col">
|
||||
{% module Template("cards/spot-age.html", web_ui_options=web_ui_options) %}
|
||||
</div>
|
||||
<div class="col">
|
||||
{% module Template("cards/basemap.html", web_ui_options=web_ui_options) %}
|
||||
</div>
|
||||
<div class="col">
|
||||
{% module Template("cards/map-features.html", web_ui_options=web_ui_options) %}
|
||||
</div>
|
||||
@@ -65,14 +68,20 @@
|
||||
<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>
|
||||
|
||||
<script>
|
||||
let spotProvidersEnabledByDefault = {% raw json_encode(web_ui_options["spot-providers-enabled-by-default"]) %};
|
||||
</script>
|
||||
<script src="/js/common.js?v=1774598773"></script>
|
||||
<script src="/js/spotsbandsandmap.js?v=1774598773"></script>
|
||||
<script src="/js/map.js?v=1774598773"></script>
|
||||
<script src="/js/common.js?v=1776849830"></script>
|
||||
<script src="/js/spotsbandsandmap.js?v=1776849830"></script>
|
||||
<script src="/js/map.js?v=1776849830"></script>
|
||||
<script>$(document).ready(function() { $("#nav-link-map").addClass("active"); }); <!-- highlight active page in nav --></script>
|
||||
|
||||
{% end %}
|
||||
@@ -74,12 +74,15 @@
|
||||
<div class="col">
|
||||
{% module Template("cards/table-columns-spots.html", web_ui_options=web_ui_options) %}
|
||||
</div>
|
||||
<div class="col">
|
||||
{% module Template("cards/audio.html", web_ui_options=web_ui_options) %}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div id="table-container">
|
||||
<table id="table" class="table"><thead><tr class="table-primary"></tr></thead><tbody></tbody></table>
|
||||
<table id="table" class="table"><thead><tr></tr></thead><tbody></tbody></table>
|
||||
</div>
|
||||
|
||||
</div>
|
||||
@@ -87,9 +90,9 @@
|
||||
<script>
|
||||
let spotProvidersEnabledByDefault = {% raw json_encode(web_ui_options["spot-providers-enabled-by-default"]) %};
|
||||
</script>
|
||||
<script src="/js/common.js?v=1774598773"></script>
|
||||
<script src="/js/spotsbandsandmap.js?v=1774598773"></script>
|
||||
<script src="/js/spots.js?v=1774598773"></script>
|
||||
<script src="/js/common.js?v=1776849830"></script>
|
||||
<script src="/js/spotsbandsandmap.js?v=1776849830"></script>
|
||||
<script src="/js/spots.js?v=1776849830"></script>
|
||||
<script>$(document).ready(function() { $("#nav-link-spots").addClass("active"); }); <!-- highlight active page in nav --></script>
|
||||
|
||||
{% end %}
|
||||
@@ -1,10 +1,68 @@
|
||||
{% extends "base.html" %}
|
||||
{% block content %}
|
||||
|
||||
<div id="status-container" class="row row-cols-1 row-cols-md-4 g-4 mt-4"></div>
|
||||
<div class="card mt-5">
|
||||
<div class="card-header">
|
||||
Spothole
|
||||
</div>
|
||||
<div class="card-body">
|
||||
<div class="row row-cols-1 row-cols-md-4 g-4 mb-2">
|
||||
<div class="col"><strong>Metadata</strong></div>
|
||||
<div class="col">Software Version: <span id="software-version"></span></div>
|
||||
<div class="col">Owner Callsign: <span id="server-owner-callsign"></span></div>
|
||||
<div class="col">Up since: <span id="up-since"></span></div>
|
||||
</div>
|
||||
<div class="row row-cols-1 row-cols-md-4 g-4 mb-2">
|
||||
<div class="col"><strong>Performance</strong></div>
|
||||
<div class="col">Memory Use: <span id="memory-use"></span></div>
|
||||
<div class="col">Total Spots: <span id="total-spots"></span></div>
|
||||
<div class="col">Total Alerts: <span id="total-alerts"></span></div>
|
||||
</div>
|
||||
<div class="row row-cols-1 row-cols-md-4 g-4 mb-2">
|
||||
<div class="col"><strong>Web Server</strong></div>
|
||||
<div class="col">Status: <span id="web-server-status"></span></div>
|
||||
<div class="col">Last API call: <span id="web-server-last-api"></span></div>
|
||||
<div class="col">Last page req: <span id="web-server-last-page"></span></div>
|
||||
</div>
|
||||
<div class="row row-cols-1 row-cols-md-4 g-4 mb-2">
|
||||
<div class="col"><strong>Cleanup Service</strong></div>
|
||||
<div class="col">Status: <span id="cleanup-status"></span></div>
|
||||
<div class="col">Last ran: <span id="cleanup-last-ran"></span></div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<script src="/js/common.js?v=1774598773"></script>
|
||||
<script src="/js/status.js?v=1774598773"></script>
|
||||
<script>$(document).ready(function() { $("#nav-link-status").addClass("active"); }); <!-- highlight active page in nav --></script>
|
||||
<div class="card mt-3">
|
||||
<div class="card-header">
|
||||
Spot Providers
|
||||
</div>
|
||||
<div class="card-body" id="spot-providers-status-container">
|
||||
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="card mt-3">
|
||||
<div class="card-header">
|
||||
Alert Providers
|
||||
</div>
|
||||
<div class="card-body" id="alert-providers-status-container">
|
||||
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="card mt-3">
|
||||
<div class="card-header">
|
||||
Solar/Band Conditions Providers
|
||||
</div>
|
||||
<div class="card-body" id="condition-providers-status-container">
|
||||
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<script src="/js/common.js?v=1776849830"></script>
|
||||
<script src="/js/status.js?v=1776849830"></script>
|
||||
<script>
|
||||
$(document).ready(function() { $("#nav-link-status").addClass("active"); }); <!-- highlight active page in nav -->
|
||||
</script>
|
||||
|
||||
{% end %}
|
||||
@@ -1,4 +1,4 @@
|
||||
<label class="form-check-label" for="band-color-scheme">Band color scheme</label><br/>
|
||||
<label class="form-check-label form-label" for="band-color-scheme">Band color scheme</label><br/>
|
||||
<select id="band-color-scheme" class="storeable-select form-select d-inline-block" oninput="setBandColorSchemeFromUI();" style="display: inline-block;">
|
||||
<option value="PSK Reporter" {% if web_ui_options["band-color-scheme-default"] == "PSK Reporter" %}selected{% end %}>PSK Reporter</option>
|
||||
<option value="PSK Reporter (Adjusted)" {% if web_ui_options["band-color-scheme-default"] == "PSK Reporter (Adjusted)" %}selected{% end %}>PSK Reporter (Adjusted)</option>
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
<label class="form-check-label" for="color-scheme">UI color scheme</label>
|
||||
<label class="form-check-label form-label" for="color-scheme">UI color scheme</label>
|
||||
<select id="color-scheme" class="storeable-select form-select d-inline-block" oninput="setColorSchemeFromUI();" style="display: inline-block;">
|
||||
<option value="auto" {% if web_ui_options["color-scheme-default"] == "auto" %}selected{% end %}>Automatic</option>
|
||||
<option value="light" {% if web_ui_options["color-scheme-default"] == "light" %}selected{% end %}>Light</option>
|
||||
|
||||
@@ -4,7 +4,7 @@
|
||||
Display
|
||||
</div>
|
||||
<div class="col-auto d-inline-flex">
|
||||
<button id="close-display-button" type="button" class="btn-close btn-close-white" aria-label="Close" onclick="closeDisplayPanel();"></button>
|
||||
<button id="close-display-button" type="button" class="btn-close btn-close" aria-label="Close" onclick="closeDisplayPanel();"></button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -4,7 +4,7 @@
|
||||
Filters
|
||||
</div>
|
||||
<div class="col-auto d-inline-flex">
|
||||
<button id="close-filters-button" type="button" class="btn-close btn-close-white" aria-label="Close" onclick="closeFiltersPanel();"></button>
|
||||
<button id="close-filters-button" type="button" class="btn-close btn-close" aria-label="Close" onclick="closeFiltersPanel();"></button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -13,8 +13,14 @@ info:
|
||||
|
||||
## Changelog
|
||||
|
||||
### 1.2
|
||||
|
||||
* Added `/dxstats` endpoint for inter-continent DX spot statistics.
|
||||
* Added `/solar` endpoint for solar and propagation conditions.
|
||||
* Added `solar_condition_providers` array to the `/status` response.
|
||||
|
||||
### 1.1
|
||||
|
||||
|
||||
* Added Server-Sent Event API endpoints for spots and alerts.
|
||||
* Removed band colour and icon information from spots.
|
||||
* Moved activation_score from top-level in Spot and Alert to be part of the SIGRef
|
||||
@@ -23,7 +29,7 @@ info:
|
||||
license:
|
||||
name: The Unlicense
|
||||
url: https://unlicense.org/#the-unlicense
|
||||
version: v1.1
|
||||
version: v1.2
|
||||
servers:
|
||||
- url: https://spothole.app/api/v1
|
||||
paths:
|
||||
@@ -288,7 +294,7 @@ paths:
|
||||
description: Limit the alerts to only ones that the system found out about at this time or later. Time in UTC seconds since UNIX epoch. If you are using a front-end that tracks the last time it queried the API and requests alerts since then, you want *this* version of the query parameter, not "since", because otherwise it may miss things. The logic is "greater than" rather than "greater than or equal to", so you can submit the time of the last received item back to this call and you will get all the more recent alerts back, without duplicating the previous latest spot.
|
||||
required: false
|
||||
schema:
|
||||
type: integer
|
||||
type: number
|
||||
- name: max_duration
|
||||
in: query
|
||||
description: Limit the alerts to only ones with a duration of this many seconds or less. Duration is end time minus start time, if end time is set, otherwise the activation is assumed to be short and therefore to always pass this check. This is useful to filter out people who alert POTA activations lasting months or even years, but note it will also include multi-day or multi-week DXpeditions that you might otherwise be interested in. See the dxpeditions_skip_max_duration_check parameter for the workaround.
|
||||
@@ -401,6 +407,61 @@ paths:
|
||||
$ref: '#/components/schemas/AlertStream'
|
||||
|
||||
|
||||
/solar:
|
||||
get:
|
||||
tags:
|
||||
- Propagation & DX
|
||||
summary: Get solar and band conditions
|
||||
description: Returns the current solar conditions and HF/VHF propagation condition summaries. This data is sourced from external providers (e.g. HamQSL) and updated periodically. All fields may be null if no provider has successfully fetched data yet.
|
||||
operationId: solar
|
||||
responses:
|
||||
'200':
|
||||
description: Success
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: '#/components/schemas/SolarConditions'
|
||||
|
||||
|
||||
/dxstats:
|
||||
get:
|
||||
tags:
|
||||
- Propagation & DX
|
||||
summary: Get spot counts by continent and band
|
||||
description: Returns a three-level nested object of spot counts from the current spot database, grouped by DE continent, then DX continent, then band. Only spots in the last hour are counted, regardless of what the server owner has set the spot expiry time to.
|
||||
operationId: dxstats
|
||||
responses:
|
||||
'200':
|
||||
description: Success
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
type: object
|
||||
description: Spot counts keyed by DE continent
|
||||
additionalProperties:
|
||||
type: object
|
||||
description: Spot counts keyed by DX continent
|
||||
additionalProperties:
|
||||
type: object
|
||||
description: Spot counts keyed by band
|
||||
properties:
|
||||
160m: { type: integer }
|
||||
80m: { type: integer }
|
||||
60m: { type: integer }
|
||||
40m: { type: integer }
|
||||
30m: { type: integer }
|
||||
20m: { type: integer }
|
||||
17m: { type: integer }
|
||||
15m: { type: integer }
|
||||
12m: { type: integer }
|
||||
10m: { type: integer }
|
||||
6m: { type: integer }
|
||||
example:
|
||||
EU:
|
||||
NA: { 20m: 42, 17m: 7, 15m: 3, 10m: 0, 6m: 0, 160m: 0, 80m: 1, 60m: 0, 40m: 5, 30m: 2, 12m: 0 }
|
||||
EU: { 20m: 18, 17m: 2, 15m: 0, 10m: 0, 6m: 1, 160m: 0, 80m: 4, 60m: 0, 40m: 9, 30m: 1, 12m: 0 }
|
||||
|
||||
|
||||
/status:
|
||||
get:
|
||||
tags:
|
||||
@@ -476,6 +537,11 @@ paths:
|
||||
description: An array of all the alert providers.
|
||||
items:
|
||||
$ref: '#/components/schemas/AlertProviderStatus'
|
||||
solar_condition_providers:
|
||||
type: array
|
||||
description: An array of all the solar conditions providers.
|
||||
items:
|
||||
$ref: '#/components/schemas/SolarConditionsProviderStatus'
|
||||
|
||||
|
||||
/options:
|
||||
@@ -771,7 +837,6 @@ paths:
|
||||
type: string
|
||||
example: "Failed"
|
||||
|
||||
|
||||
components:
|
||||
schemas:
|
||||
Source:
|
||||
@@ -1151,7 +1216,7 @@ components:
|
||||
SpotStream:
|
||||
type: object
|
||||
description: A server-sent event containing a spot
|
||||
required: [data]
|
||||
required: [ data ]
|
||||
properties:
|
||||
data:
|
||||
$ref: "#/components/schemas/Spot"
|
||||
@@ -1252,7 +1317,7 @@ components:
|
||||
AlertStream:
|
||||
type: object
|
||||
description: A server-sent event containing an alert
|
||||
required: [data]
|
||||
required: [ data ]
|
||||
properties:
|
||||
data:
|
||||
$ref: "#/components/schemas/Alert"
|
||||
@@ -1328,4 +1393,232 @@ components:
|
||||
ref_regex:
|
||||
type: string
|
||||
description: Regex that matches this SIG's reference IDs. Generally for Spothole's own internal use, clients probably won't need this.
|
||||
example: "[A-Z]{2}\\-\\d+"
|
||||
example: "[A-Z]{2}\\-\\d+"
|
||||
|
||||
SolarConditions:
|
||||
type: object
|
||||
description: Current solar and propagation conditions. All fields may be null if no provider has successfully fetched data yet.
|
||||
properties:
|
||||
updated:
|
||||
type: number
|
||||
description: Time that the data was last updated, UTC seconds since UNIX epoch
|
||||
example: 1759579508
|
||||
sfi:
|
||||
type: integer
|
||||
description: Solar Flux Index (SFI)
|
||||
example: 170
|
||||
a_index:
|
||||
type: integer
|
||||
description: Daily geomagnetic activity index
|
||||
example: 7
|
||||
k_index:
|
||||
type: integer
|
||||
description: 3-hour geomagnetic activity index, 0–9
|
||||
example: 2
|
||||
xray:
|
||||
type: string
|
||||
description: Current X-ray flux class
|
||||
example: "B2.3"
|
||||
proton_flux:
|
||||
type: integer
|
||||
description: Proton flux level
|
||||
example: 1
|
||||
electron_flux:
|
||||
type: integer
|
||||
description: Electron flux level
|
||||
example: 631
|
||||
aurora:
|
||||
type: integer
|
||||
description: Aurora activity level
|
||||
example: 5
|
||||
aurora_latitude:
|
||||
type: number
|
||||
description: Lowest latitude at which aurora should be visible
|
||||
example: 66.3
|
||||
sunspots:
|
||||
type: integer
|
||||
description: Sunspot count
|
||||
example: 87
|
||||
solar_wind:
|
||||
type: number
|
||||
description: Solar wind speed in km/s
|
||||
example: 356.6
|
||||
magnetic_field:
|
||||
type: number
|
||||
description: Interplanetary magnetic field strength in nT
|
||||
example: 2.5
|
||||
geomag_field:
|
||||
type: string
|
||||
description: Geomagnetic field condition summary
|
||||
example: "Active"
|
||||
geomag_noise:
|
||||
type: string
|
||||
description: Geomagnetic background noise level on HF, in S-units
|
||||
example: "S0"
|
||||
hf_conditions:
|
||||
type: object
|
||||
description: HF propagation condition assessments, keyed by "{band}-{time}" e.g. "80m-40m-day"
|
||||
properties:
|
||||
80m-40m-day:
|
||||
type: string
|
||||
enum: [ Good, Fair, Poor ]
|
||||
80m-40m-night:
|
||||
type: string
|
||||
enum: [ Good, Fair, Poor ]
|
||||
30m-20m-day:
|
||||
type: string
|
||||
enum: [ Good, Fair, Poor ]
|
||||
30m-20m-night:
|
||||
type: string
|
||||
enum: [ Good, Fair, Poor ]
|
||||
17m-15m-day:
|
||||
type: string
|
||||
enum: [ Good, Fair, Poor ]
|
||||
17m-15m-night:
|
||||
type: string
|
||||
enum: [ Good, Fair, Poor ]
|
||||
12m-10m-day:
|
||||
type: string
|
||||
enum: [ Good, Fair, Poor ]
|
||||
12m-10m-night:
|
||||
type: string
|
||||
enum: [ Good, Fair, Poor ]
|
||||
vhf_conditions:
|
||||
type: object
|
||||
description: VHF propagation condition assessments, keyed by condition name
|
||||
properties:
|
||||
vhf_aurora_northern_hemi:
|
||||
type: string
|
||||
description: VHF aurora propagation condition for the northern hemisphere
|
||||
example: "Band Closed"
|
||||
es_2m_europe:
|
||||
type: string
|
||||
description: Sporadic-E propagation condition on 2m for Europe
|
||||
example: "Band Closed"
|
||||
es_4m_europe:
|
||||
type: string
|
||||
description: Sporadic-E propagation condition on 4m for Europe
|
||||
example: "Band Closed"
|
||||
es_6m_europe:
|
||||
type: string
|
||||
description: Sporadic-E propagation condition on 6m for Europe
|
||||
example: "Band Closed"
|
||||
es_2m_na:
|
||||
type: string
|
||||
description: Sporadic-E propagation condition on 2m for North America
|
||||
example: "Band Closed"
|
||||
k_index_forecast:
|
||||
type: object
|
||||
description: >
|
||||
NOAA Kp index 3-day forecast. Keys are UNIX timestamps (UTC seconds since epoch) for the
|
||||
start of each 3-hour period. Values are the forecast Kp index (0–9) for that period.
|
||||
Only forecast values are included; observed actuals (shown in parentheses in the source
|
||||
data) are discarded.
|
||||
additionalProperties:
|
||||
type: number
|
||||
minimum: 0
|
||||
maximum: 9
|
||||
example:
|
||||
"1743638400.0": 4.0
|
||||
"1743649200.0": 5.67
|
||||
"1743660000.0": 3.67
|
||||
solar_storm_forecast:
|
||||
type: object
|
||||
description: >
|
||||
NOAA Solar Radiation Storm forecast — 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:
|
||||
type: integer
|
||||
minimum: 0
|
||||
maximum: 100
|
||||
example:
|
||||
"1743638400.0": 50
|
||||
"1743724800.0": 50
|
||||
"1743811200.0": 25
|
||||
blackout_forecast_r1r2:
|
||||
type: object
|
||||
description: >
|
||||
NOAA Radio Blackout forecast — 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:
|
||||
type: integer
|
||||
minimum: 0
|
||||
maximum: 100
|
||||
example:
|
||||
"1743638400.0": 55
|
||||
"1743724800.0": 55
|
||||
"1743811200.0": 55
|
||||
blackout_forecast_r3_or_greater:
|
||||
type: object
|
||||
description: >
|
||||
NOAA Radio Blackout forecast — 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:
|
||||
type: integer
|
||||
minimum: 0
|
||||
maximum: 100
|
||||
example:
|
||||
"1743638400.0": 25
|
||||
"1743724800.0": 25
|
||||
"1743811200.0": 25
|
||||
xray_desc:
|
||||
type: string
|
||||
description: HF radio blackout risk description, derived from the X-ray flux class.
|
||||
example: "No significant radio blackout"
|
||||
radio_blackout_scale:
|
||||
type: integer
|
||||
description: HF radio blackout scale number (R0-R5), derived from the X-ray flux class.
|
||||
minimum: 0
|
||||
maximum: 5
|
||||
example: 0
|
||||
proton_flux_desc:
|
||||
type: string
|
||||
description: Solar radiation storm level description, derived from proton flux.
|
||||
example: "No solar radiation storm"
|
||||
solar_storm_scale:
|
||||
type: integer
|
||||
description: Solar radiation storm scale number (S0-S5), derived from proton flux. S0 = none, S5 = extreme.
|
||||
minimum: 0
|
||||
maximum: 5
|
||||
example: 0
|
||||
geomag_storm_desc:
|
||||
type: string
|
||||
description: Geomagnetic storm level description, derived from K-index.
|
||||
example: "Quiet"
|
||||
geomag_storm_scale:
|
||||
type: integer
|
||||
description: Geomagnetic storm scale number (G0-G5), derived from K-index. G0 = none, G5 = extreme.
|
||||
minimum: 0
|
||||
maximum: 5
|
||||
example: 0
|
||||
band_conditions_desc:
|
||||
type: string
|
||||
description: Overall HF band conditions summary, derived from Solar Flux Index.
|
||||
example: "Fair to good conditions on all bands up to 10m"
|
||||
electron_flux_desc:
|
||||
type: string
|
||||
description: Electron flux impact description, derived from electron flux level.
|
||||
example: "No impact"
|
||||
|
||||
SolarConditionsProviderStatus:
|
||||
type: object
|
||||
properties:
|
||||
name:
|
||||
type: string
|
||||
description: The name of the provider.
|
||||
example: HamQSL
|
||||
enabled:
|
||||
type: boolean
|
||||
description: Whether the provider is enabled or not.
|
||||
example: true
|
||||
status:
|
||||
type: string
|
||||
description: The status of the provider.
|
||||
example: OK
|
||||
last_updated:
|
||||
type: number
|
||||
description: The last time at which this provider received data, UTC seconds since UNIX epoch. If this is zero, the provider has never updated.
|
||||
example: 1759579508
|
||||
BIN
webassets/audio/ping.mp3
Normal file
BIN
webassets/audio/ping.mp3
Normal file
Binary file not shown.
@@ -3,6 +3,9 @@
|
||||
.navbar-nav .nav-link.active {
|
||||
font-weight: bold;
|
||||
}
|
||||
.navbar-nav .nav-link i {
|
||||
margin-right: 0.2em;
|
||||
}
|
||||
|
||||
/* 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. */
|
||||
@@ -97,11 +100,6 @@ div.appearing-panel {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.spothole-card-text {
|
||||
line-height: 2.5em !important;
|
||||
}
|
||||
|
||||
|
||||
|
||||
/* SPOTS/ALERTS PAGES, MAIN TABLE */
|
||||
|
||||
@@ -190,16 +188,16 @@ tr.new td {
|
||||
}
|
||||
}
|
||||
|
||||
/* 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;
|
||||
|
||||
/* TABLE */
|
||||
#table-container {
|
||||
border-radius: 5px;
|
||||
border: 1px solid var(--bs-primary-border-subtle);
|
||||
overflow: hidden;
|
||||
}
|
||||
[data-bs-theme=dark] tr.table-danger {
|
||||
--bs-table-bg: #74272e;
|
||||
--bs-table-border-color: #530208;
|
||||
--bs-table-color: white;
|
||||
|
||||
#table-container table{
|
||||
margin-bottom: 0;
|
||||
}
|
||||
|
||||
|
||||
@@ -216,10 +214,8 @@ div#map {
|
||||
.leaflet-container {
|
||||
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%);
|
||||
.leaflet-control-attribution {
|
||||
background: none;
|
||||
}
|
||||
|
||||
/* Make buttons overlaid on the map have a non-transparent fill so you can see the text better */
|
||||
|
||||
@@ -54,25 +54,25 @@ function updateTable() {
|
||||
let table = $("#table");
|
||||
table.find('thead tr').empty();
|
||||
if (showStartTime) {
|
||||
table.find('thead tr').append(`<th>${useLocalTime ? "Start (Local)" : "Start UTC"}</th>`);
|
||||
table.find('thead tr').append(`<th class="bg-primary-subtle">${useLocalTime ? "Start (Local)" : "Start UTC"}</th>`);
|
||||
}
|
||||
if (showEndTime) {
|
||||
table.find('thead tr').append(`<th>${useLocalTime ? "End (Local)" : "End UTC"}</th>`);
|
||||
table.find('thead tr').append(`<th class="bg-primary-subtle">${useLocalTime ? "End (Local)" : "End UTC"}</th>`);
|
||||
}
|
||||
if (showDX) {
|
||||
table.find('thead tr').append(`<th>DX</th>`);
|
||||
table.find('thead tr').append(`<th class="bg-primary-subtle">DX</th>`);
|
||||
}
|
||||
if (showFreqsModes) {
|
||||
table.find('thead tr').append(`<th class='hideonmobile'>Freq<span class='hideonmobile'>uencie</span>s & Modes</th>`);
|
||||
table.find('thead tr').append(`<th class='bg-primary-subtle hideonmobile'>Freq<span class='hideonmobile'>uencie</span>s & Modes</th>`);
|
||||
}
|
||||
if (showComment) {
|
||||
table.find('thead tr').append(`<th class='hideonmobile'>Comment</th>`);
|
||||
table.find('thead tr').append(`<th class='bg-primary-subtle hideonmobile'>Comment</th>`);
|
||||
}
|
||||
if (showSource) {
|
||||
table.find('thead tr').append(`<th class='hideonmobile'>Source</th>`);
|
||||
table.find('thead tr').append(`<th class='bg-primary-subtle hideonmobile'>Source</th>`);
|
||||
}
|
||||
if (showRef) {
|
||||
table.find('thead tr').append(`<th class='hideonmobile'>Ref.</th>`);
|
||||
table.find('thead tr').append(`<th class='bg-primary-subtle hideonmobile'>Ref.</th>`);
|
||||
}
|
||||
|
||||
table.find('tbody').empty();
|
||||
@@ -86,22 +86,22 @@ function updateTable() {
|
||||
later = alerts.filter(a => moment.unix(a["start_time"]).utc().subtract(24, 'hours').isSameOrAfter());
|
||||
|
||||
if (onNow.length > 0) {
|
||||
table.find('tbody').append('<tr class="table-primary"><td colspan="100" style="text-align:center;">On Now</td></tr>');
|
||||
table.find('tbody').append('<tr><td colspan="100" class="bg-primary-subtle" style="text-align:center;">On Now</td></tr>');
|
||||
addAlertRowsToTable(table.find('tbody'), onNow);
|
||||
}
|
||||
|
||||
if (next24h.length > 0) {
|
||||
table.find('tbody').append('<tr class="table-primary"><td colspan="100" style="text-align:center;">Starting within 24 hours</td></tr>');
|
||||
table.find('tbody').append('<tr><td colspan="100" class="bg-primary-subtle" style="text-align:center;">Starting within 24 hours</td></tr>');
|
||||
addAlertRowsToTable(table.find('tbody'), next24h);
|
||||
}
|
||||
|
||||
if (later.length > 0) {
|
||||
table.find('tbody').append('<tr class="table-primary"><td colspan="100" style="text-align:center;">Starting later </td></tr>');
|
||||
table.find('tbody').append('<tr><td colspan="100" class="bg-primary-subtle" style="text-align:center;">Starting later </td></tr>');
|
||||
addAlertRowsToTable(table.find('tbody'), later);
|
||||
}
|
||||
|
||||
if (onNow.length == 0 && next24h.length == 0 && later.length == 0) {
|
||||
table.find('tbody').append('<tr class="table-danger"><td colspan="100" style="text-align:center;">No alerts match your filters.</td></tr>');
|
||||
table.find('tbody').append('<tr class="bg-danger-subtle"><td colspan="100" style="text-align:center;">No alerts match your filters.</td></tr>');
|
||||
}
|
||||
}
|
||||
|
||||
@@ -306,33 +306,6 @@ function filtersUpdated() {
|
||||
saveSettings();
|
||||
}
|
||||
|
||||
// React to toggling/closing panels
|
||||
function toggleFiltersPanel() {
|
||||
// If we are going to display the filters panel, hide the display panel
|
||||
if (!$("#filters-area").is(":visible") && $("#display-area").is(":visible")) {
|
||||
$("#display-area").hide();
|
||||
$("#display-button").button("toggle");
|
||||
}
|
||||
$("#filters-area").toggle();
|
||||
}
|
||||
function closeFiltersPanel() {
|
||||
$("#filters-button").button("toggle");
|
||||
$("#filters-area").hide();
|
||||
}
|
||||
|
||||
function toggleDisplayPanel() {
|
||||
// If we are going to display status, load the data for the status panel, and hide the filters panel
|
||||
if (!$("#display-area").is(":visible") && $("#filters-area").is(":visible")) {
|
||||
$("#filters-area").hide();
|
||||
$("#filters-button").button("toggle");
|
||||
}
|
||||
$("#display-area").toggle();
|
||||
}
|
||||
function closeDisplayPanel() {
|
||||
$("#display-button").button("toggle");
|
||||
$("#display-area").hide();
|
||||
}
|
||||
|
||||
// Startup
|
||||
$(document).ready(function() {
|
||||
// Call loadOptions(), this will then trigger loading alerts and setting up timers.
|
||||
|
||||
@@ -265,33 +265,6 @@ function displayUpdated() {
|
||||
saveSettings();
|
||||
}
|
||||
|
||||
// React to toggling/closing panels
|
||||
function toggleFiltersPanel() {
|
||||
// If we are going to show the filters panel, hide the display panel
|
||||
if (!$("#filters-area").is(":visible") && $("#display-area").is(":visible")) {
|
||||
$("#display-area").hide();
|
||||
$("#display-button").button("toggle");
|
||||
}
|
||||
$("#filters-area").toggle();
|
||||
}
|
||||
function closeFiltersPanel() {
|
||||
$("#filters-button").button("toggle");
|
||||
$("#filters-area").hide();
|
||||
}
|
||||
|
||||
function toggleDisplayPanel() {
|
||||
// If we are going to show the display panel, hide the filters panel
|
||||
if (!$("#display-area").is(":visible") && $("#filters-area").is(":visible")) {
|
||||
$("#filters-area").hide();
|
||||
$("#filters-button").button("toggle");
|
||||
}
|
||||
$("#display-area").toggle();
|
||||
}
|
||||
function closeDisplayPanel() {
|
||||
$("#display-button").button("toggle");
|
||||
$("#display-area").hide();
|
||||
}
|
||||
|
||||
// Startup
|
||||
$(document).ready(function() {
|
||||
// Call loadOptions(), this will then trigger loading spots and setting up timers.
|
||||
|
||||
@@ -2,6 +2,39 @@
|
||||
var options = {};
|
||||
// Last time we updated the spots/alerts list on display.
|
||||
var lastUpdateTime;
|
||||
// Normally load user settings from local storage, unless embedded mode is in use
|
||||
let useLocalStorage = true;
|
||||
|
||||
// Save settings to local storage. Suppressed if "use local storage" is false.
|
||||
function saveSettings() {
|
||||
if (useLocalStorage) {
|
||||
// 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 "use local storage" is false.
|
||||
function loadSettings() {
|
||||
if (useLocalStorage) {
|
||||
// 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)));
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// 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
|
||||
@@ -92,14 +125,14 @@ function allFilterOptionsSelected(parameter) {
|
||||
}
|
||||
|
||||
|
||||
// Generate a filter card with multiple toggle buttons plus All/None buttons.
|
||||
// Generate a filter card with inline checkboxes plus All/None links.
|
||||
function generateMultiToggleFilterCard(elementID, filterQuery, options) {
|
||||
// Create a button for each option
|
||||
var $row = $('<div>');
|
||||
options.forEach(o => {
|
||||
$(elementID).append(`<input type="checkbox" class="btn-check filter-button-${filterQuery} storeable-checkbox" name="options" id="filter-button-${filterQuery}-${o}" value="${o}" autocomplete="off" onClick="filtersUpdated()" checked><label class="btn btn-outline-success" for="filter-button-${filterQuery}-${o}">${o}</label> `);
|
||||
$row.append(`<div class="form-check form-check-inline"><input type="checkbox" class="form-check-input filter-button-${filterQuery} storeable-checkbox" id="filter-button-${filterQuery}-${o}" value="${o}" autocomplete="off" onClick="filtersUpdated()" checked><label class="form-check-label" for="filter-button-${filterQuery}-${o}">${o}</label></div>`);
|
||||
});
|
||||
// Create All/None buttons
|
||||
$(elementID).append(` <span style="display: inline-block"><button id="filter-button-${filterQuery}-all" type="button" class="btn btn-outline-secondary" onclick="toggleFilterButtons('${filterQuery}', true);">All</button> <button id="filter-button-${filterQuery}-none" type="button" class="btn btn-outline-secondary" onclick="toggleFilterButtons('${filterQuery}', false);">None</button></span>`);
|
||||
$(elementID).append($row);
|
||||
$(elementID).append(`<div class="mt-1"><a href="#" onclick="toggleFilterButtons('${filterQuery}', true); return false;">All</a> <a href="#" onclick="toggleFilterButtons('${filterQuery}', false); return false;">None</a></div>`);
|
||||
}
|
||||
|
||||
// Method called when "All" or "None" is clicked
|
||||
@@ -183,6 +216,39 @@ function listenForOSThemeChange() {
|
||||
});
|
||||
}
|
||||
|
||||
// Panel toggle functions
|
||||
const PANELS = [
|
||||
{ area: "#filters-area",button: "#filters-button" },
|
||||
{ area: "#display-area", button: "#display-button" },
|
||||
];
|
||||
|
||||
// Toggle a panel open or closed. If opening, all other visible panels are closed first.
|
||||
// areaId is the jQuery selector for the panel's content area, e.g. "#filters-area".
|
||||
function togglePanel(areaId) {
|
||||
if (!$(areaId).is(":visible")) {
|
||||
PANELS.forEach(p => {
|
||||
if (p.area !== areaId && $(p.area).is(":visible")) {
|
||||
$(p.area).hide();
|
||||
$(p.button).button("toggle");
|
||||
}
|
||||
});
|
||||
}
|
||||
$(areaId).toggle();
|
||||
}
|
||||
|
||||
// Close a panel and deactivate its toggle button.
|
||||
function closePanel(areaId) {
|
||||
const panel = PANELS.find(p => p.area === areaId);
|
||||
if (panel) { $(panel.button).button("toggle"); }
|
||||
$(areaId).hide();
|
||||
}
|
||||
|
||||
function toggleFiltersPanel() { togglePanel("#filters-area"); }
|
||||
function closeFiltersPanel() { closePanel("#filters-area"); }
|
||||
function toggleDisplayPanel() { togglePanel("#display-area"); }
|
||||
function closeDisplayPanel() { closePanel("#display-area"); }
|
||||
|
||||
|
||||
// Startup
|
||||
$(document).ready(function() {
|
||||
usePreferredTheme();
|
||||
|
||||
403
webassets/js/conditions.js
Normal file
403
webassets/js/conditions.js
Normal file
@@ -0,0 +1,403 @@
|
||||
// Cache for the full dxstats API response, so we can reload on the fly if the user changes the value of their continent
|
||||
// in the select box
|
||||
let dxStatsData = null;
|
||||
// Forecast chart
|
||||
let kpChart = null;
|
||||
|
||||
// Load solar conditions
|
||||
function loadSolarConditions() {
|
||||
$.getJSON('/api/v1/solar', function (jsonData) {
|
||||
|
||||
// HF
|
||||
|
||||
const hfConditionClass = {'Good': 'bg-success-subtle', 'Fair': 'bg-warning-subtle', 'Poor': 'bg-danger-subtle'};
|
||||
|
||||
if (jsonData.hf_conditions) {
|
||||
Object.entries(jsonData.hf_conditions).forEach(function ([key, condition]) {
|
||||
const cell = $('#hf-conditions-' + key);
|
||||
cell.text(condition);
|
||||
cell.addClass(hfConditionClass[condition]);
|
||||
});
|
||||
}
|
||||
|
||||
// VHF
|
||||
|
||||
if (jsonData.vhf_conditions) {
|
||||
Object.entries(jsonData.vhf_conditions).forEach(function ([key, condition]) {
|
||||
const cell = $('#vhf-conditions-' + key);
|
||||
cell.text(condition);
|
||||
let vhfClass;
|
||||
if (condition === 'Band Closed') {
|
||||
vhfClass = 'bg-danger-subtle';
|
||||
} else if (condition.includes('High')) {
|
||||
vhfClass = 'bg-warning-subtle';
|
||||
} else {
|
||||
vhfClass = 'bg-success-subtle';
|
||||
}
|
||||
cell.addClass(vhfClass);
|
||||
});
|
||||
}
|
||||
if (jsonData.aurora_latitude !== null && jsonData.aurora_latitude !== undefined) {
|
||||
$('#vhf-conditions-aurora-lat').text(jsonData.aurora_latitude + '°');
|
||||
}
|
||||
|
||||
// Solar Weather
|
||||
|
||||
const swFields = {
|
||||
'sfi': 'sw-sfi',
|
||||
'sunspots': 'sw-sunspots',
|
||||
'band_conditions_desc': 'sw-solar-flux-desc',
|
||||
'k_index': 'sw-k-index',
|
||||
'a_index': 'sw-a-index',
|
||||
'geomag_field': 'sw-geomag-field',
|
||||
'geomag_storm_scale': 'sw-geomag-storm-scale',
|
||||
'geomag_storm_desc': 'sw-geomag-storm-desc',
|
||||
'geomag_noise': 'sw-geomag-noise',
|
||||
'xray': 'sw-xray',
|
||||
'radio_blackout_scale': 'sw-radio-blackout-scale',
|
||||
'xray_desc': 'sw-xray-desc',
|
||||
'proton_flux': 'sw-proton-flux',
|
||||
'solar_storm_scale': 'sw-solar-storm-scale',
|
||||
'proton_flux_desc': 'sw-proton-desc',
|
||||
'electron_flux': 'sw-electron-flux',
|
||||
'electron_flux_desc': 'sw-electron-desc',
|
||||
};
|
||||
Object.entries(swFields).forEach(function ([field, id]) {
|
||||
const val = jsonData[field];
|
||||
if (val !== null && val !== undefined) {
|
||||
$('#' + id).text(val);
|
||||
}
|
||||
});
|
||||
|
||||
// Solar Weather - colouring
|
||||
|
||||
function applySwClass(valsId, descId, cls) {
|
||||
$('#' + valsId).addClass(cls);
|
||||
$('#' + descId).addClass(cls);
|
||||
}
|
||||
|
||||
const sfi = jsonData.sfi;
|
||||
if (sfi !== null && sfi !== undefined) {
|
||||
applySwClass('sw-solar-flux-vals', 'sw-solar-flux-desc',
|
||||
sfi > 120 ? 'bg-success-subtle' : sfi > 90 ? 'bg-warning-subtle' : 'bg-danger-subtle');
|
||||
}
|
||||
|
||||
const kIndex = jsonData.k_index;
|
||||
if (kIndex !== null && kIndex !== undefined) {
|
||||
applySwClass('sw-geomag-vals', 'sw-geomag-desc',
|
||||
kIndex < 5 ? 'bg-success-subtle' : kIndex < 6 ? 'bg-warning-subtle' : 'bg-danger-subtle');
|
||||
}
|
||||
|
||||
const xRay = jsonData.xray;
|
||||
if (xRay) {
|
||||
const letter = xRay[0].toUpperCase();
|
||||
const xRayClass = (letter === 'X') ? 'bg-danger-subtle'
|
||||
: (letter === 'M') ? 'bg-warning-subtle'
|
||||
: 'bg-success-subtle';
|
||||
applySwClass('sw-xray-vals', 'sw-xray-desc', xRayClass);
|
||||
}
|
||||
|
||||
const protonFlux = jsonData.proton_flux;
|
||||
if (protonFlux !== null && protonFlux !== undefined) {
|
||||
applySwClass('sw-proton-vals', 'sw-proton-desc',
|
||||
protonFlux <= 100 ? 'bg-success-subtle' : protonFlux <= 10000 ? 'bg-warning-subtle' : 'bg-danger-subtle');
|
||||
}
|
||||
|
||||
const electronFlux = jsonData.electron_flux;
|
||||
if (electronFlux !== null && electronFlux !== undefined) {
|
||||
applySwClass('sw-electron-vals', 'sw-electron-desc',
|
||||
electronFlux <= 100 ? 'bg-success-subtle' : electronFlux <= 1000 ? 'bg-warning-subtle' : 'bg-danger-subtle');
|
||||
}
|
||||
|
||||
// Forecast
|
||||
|
||||
renderKIndexForecast(jsonData.k_index_forecast);
|
||||
renderSolarStormForecast(jsonData.solar_storm_forecast);
|
||||
renderBlackoutForecast(jsonData.blackout_forecast_r1r2, jsonData.blackout_forecast_r3_or_greater);
|
||||
});
|
||||
}
|
||||
|
||||
// Render the K-index forecast as a Chart.js bar chart, one bar per 3-hour UTC period
|
||||
function renderKIndexForecast(data) {
|
||||
if (!data) return;
|
||||
|
||||
const entries = Object.entries(data)
|
||||
.map(([tsStr, kp]) => ({ts: parseFloat(tsStr), kp}))
|
||||
.sort((a, b) => a.ts - b.ts);
|
||||
if (entries.length === 0) return;
|
||||
|
||||
// Use a simple integer index axis: ticks at 0, 1, 2, ..., N (period boundaries) and bars
|
||||
// centred at 0.5, 1.5, ..., N-0.5 (midpoints). This guarantees tick marks fall exactly on
|
||||
// bar edges regardless of how Chart.js rounds large timestamp values.
|
||||
// "axisMin = 0" is the left/top edge of bar 0; "axisMax = N" is the right/bottom edge of bar N-1.
|
||||
const N = entries.length;
|
||||
const periodSecs = 3 * 3600;
|
||||
|
||||
// Inherit colours from Bootstrap CSS variables so that dark mode inherently works. We want bar colours that are not
|
||||
// quite as saturated as the Bootstrap success/warning/danger colours but not as desaturated as the "subtle"
|
||||
// versions, so use tinycolor to apply some transparency.
|
||||
const style = getComputedStyle(document.documentElement);
|
||||
const withAlpha = hex => tinycolor(hex).setAlpha(0.8).toRgbString();
|
||||
const colors = entries.map(e =>
|
||||
e.kp < 4.5 ? withAlpha(style.getPropertyValue('--bs-success').trim())
|
||||
: e.kp < 5.5 ? withAlpha(style.getPropertyValue('--bs-warning').trim())
|
||||
: withAlpha(style.getPropertyValue('--bs-danger').trim())
|
||||
);
|
||||
const textColor = style.getPropertyValue('--bs-body-color').trim() || '#666';
|
||||
const gridColor = style.getPropertyValue('--bs-border-color').trim() || 'rgba(128,128,128,0.3)';
|
||||
|
||||
if (kpChart) {
|
||||
kpChart.destroy();
|
||||
}
|
||||
|
||||
const isMobile = window.innerWidth < 768;
|
||||
const kpAxisTicks = {
|
||||
stepSize: 1,
|
||||
color: textColor,
|
||||
// Include geomagnetic storm levels (Gx) alongside the Kp index
|
||||
callback: v => v > 4 ? `(G${v - 4}) ${v}` : String(v),
|
||||
};
|
||||
const kpAxis = {
|
||||
min: 0,
|
||||
max: 9,
|
||||
title: {display: true, text: 'Kp', color: textColor},
|
||||
ticks: kpAxisTicks,
|
||||
grid: {color: gridColor},
|
||||
};
|
||||
// Linear scale using integer indices. Ticks at 0..N (period boundary indices);
|
||||
// the callback converts each integer index back to a UTC time string.
|
||||
// On mobile the time axis is vertical, so reverse it to keep time running top-to-bottom.
|
||||
const timeAxis = {
|
||||
type: 'linear',
|
||||
min: 0,
|
||||
max: N,
|
||||
offset: false,
|
||||
reverse: isMobile,
|
||||
title: {display: true, text: 'Time (UTC)', color: textColor},
|
||||
ticks: {
|
||||
stepSize: 1,
|
||||
color: textColor,
|
||||
maxRotation: 45,
|
||||
minRotation: 0,
|
||||
callback(value) {
|
||||
if (!Number.isInteger(value) || value < 0 || value > N) return null;
|
||||
const ts = value < N ? entries[value].ts : entries[N - 1].ts + periodSecs;
|
||||
const dt = new Date(ts * 1000);
|
||||
const h = dt.getUTCHours(), m = dt.getUTCMinutes();
|
||||
const timeStr = String(h).padStart(2, '0') + ':' + String(m).padStart(2, '0');
|
||||
if (h === 0 && m === 0) {
|
||||
return [timeStr, dt.toLocaleDateString('en-GB', {day: '2-digit', month: 'short', timeZone: 'UTC'})];
|
||||
}
|
||||
return timeStr;
|
||||
},
|
||||
},
|
||||
grid: {color: gridColor, offset: false},
|
||||
};
|
||||
|
||||
// Draw a "now" line at the current time position
|
||||
const nowLinePlugin = {
|
||||
id: 'nowLine',
|
||||
afterDraw(chart) {
|
||||
const nowTs = Date.now() / 1000;
|
||||
// Find which bar (if any) the current time falls in and compute a fractional index
|
||||
const firstTs = entries[0].ts;
|
||||
const lastTs = entries[N - 1].ts + periodSecs;
|
||||
if (nowTs < firstTs || nowTs > lastTs) return;
|
||||
const fracIndex = (nowTs - firstTs) / periodSecs;
|
||||
|
||||
const {ctx, chartArea} = chart;
|
||||
const scale = isMobile ? chart.scales.y : chart.scales.x;
|
||||
const pos = scale.getPixelForValue(fracIndex);
|
||||
|
||||
ctx.save();
|
||||
ctx.strokeStyle = textColor;
|
||||
ctx.lineWidth = 2;
|
||||
ctx.setLineDash([5, 4]);
|
||||
ctx.beginPath();
|
||||
if (isMobile) {
|
||||
ctx.moveTo(chartArea.left, pos);
|
||||
ctx.lineTo(chartArea.right, pos);
|
||||
} else {
|
||||
ctx.moveTo(pos, chartArea.top);
|
||||
ctx.lineTo(pos, chartArea.bottom);
|
||||
}
|
||||
ctx.stroke();
|
||||
ctx.setLineDash([]);
|
||||
ctx.fillStyle = textColor;
|
||||
ctx.font = '11px sans-serif';
|
||||
if (isMobile) {
|
||||
ctx.textAlign = 'right';
|
||||
ctx.textBaseline = 'bottom';
|
||||
ctx.fillText('Now', chartArea.right, pos - 3);
|
||||
} else {
|
||||
ctx.textAlign = 'left';
|
||||
ctx.textBaseline = 'top';
|
||||
ctx.fillText(' Now', pos, chartArea.top + 3);
|
||||
}
|
||||
ctx.restore();
|
||||
}
|
||||
};
|
||||
|
||||
// Bars centred at i+0.5 (midpoint between tick i and tick i+1) so each bar spans
|
||||
// exactly from tick i to tick i+1 with barPercentage/categoryPercentage = 1.0.
|
||||
const chartData = isMobile
|
||||
? entries.map((e, i) => ({x: e.kp, y: i + 0.5}))
|
||||
: entries.map((e, i) => ({x: i + 0.5, y: e.kp}));
|
||||
|
||||
kpChart = new Chart(document.getElementById('forecast-kp-chart'), {
|
||||
type: 'bar',
|
||||
data: {
|
||||
datasets: [{
|
||||
data: chartData,
|
||||
backgroundColor: colors,
|
||||
hoverBackgroundColor: colors,
|
||||
borderWidth: 0,
|
||||
barPercentage: 1.0,
|
||||
categoryPercentage: 1.0,
|
||||
}]
|
||||
},
|
||||
options: {
|
||||
responsive: true,
|
||||
// Swap axes on mobile, and change the aspect ratio
|
||||
aspectRatio: isMobile ? 0.4 : 3,
|
||||
indexAxis: isMobile ? 'y' : 'x',
|
||||
plugins: {
|
||||
legend: {
|
||||
display: false
|
||||
},
|
||||
tooltip: {
|
||||
enabled: false
|
||||
}
|
||||
},
|
||||
scales: {
|
||||
x: isMobile ? kpAxis : timeAxis,
|
||||
y: isMobile ? timeAxis : kpAxis,
|
||||
}
|
||||
},
|
||||
plugins: [nowLinePlugin],
|
||||
});
|
||||
}
|
||||
|
||||
// Render the solar storm forecast table
|
||||
function renderSolarStormForecast(data) {
|
||||
if (!data) return;
|
||||
|
||||
const entries = Object.entries(data)
|
||||
.map(([tsStr, pct]) => ({ts: parseFloat(tsStr), pct}))
|
||||
.sort((a, b) => a.ts - b.ts);
|
||||
|
||||
// Header
|
||||
const headRow = $('#forecast-solar-storm-head').empty().append('<th></th>');
|
||||
entries.forEach(({ts}) => {
|
||||
const label = new Date(ts * 1000)
|
||||
.toLocaleDateString('en-GB', {day: '2-digit', month: 'short', timeZone: 'UTC'});
|
||||
headRow.append(`<th>${label}</th>`);
|
||||
});
|
||||
|
||||
// Single data row: "S1 or greater" label + one cell per date
|
||||
const tr = $('<tr>').append('<td>S1 or greater</td>');
|
||||
entries.forEach(({pct}) => {
|
||||
const td = $('<td>').text(pct + '%');
|
||||
td.addClass(pct < 50 ? 'bg-success-subtle' : pct < 75 ? 'bg-warning-subtle' : 'bg-danger-subtle');
|
||||
tr.append(td);
|
||||
});
|
||||
$('#forecast-solar-storm-tbody').empty().append(tr);
|
||||
}
|
||||
|
||||
// Render the radio blackout forecast table
|
||||
function renderBlackoutForecast(r1r2Data, r3Data) {
|
||||
if (!r1r2Data && !r3Data) return;
|
||||
|
||||
const tsSet = new Set([
|
||||
...Object.keys(r1r2Data || {}),
|
||||
...Object.keys(r3Data || {})
|
||||
]);
|
||||
const entries = [...tsSet]
|
||||
.map(tsStr => ({
|
||||
ts: parseFloat(tsStr),
|
||||
r1r2: r1r2Data ? r1r2Data[tsStr] : undefined,
|
||||
r3: r3Data ? r3Data[tsStr] : undefined
|
||||
}))
|
||||
.sort((a, b) => a.ts - b.ts);
|
||||
|
||||
// Header
|
||||
const headRow = $('#forecast-blackout-head').empty().append('<th></th>');
|
||||
entries.forEach(({ts}) => {
|
||||
const label = new Date(ts * 1000)
|
||||
.toLocaleDateString('en-GB', {day: '2-digit', month: 'short', timeZone: 'UTC'});
|
||||
headRow.append(`<th>${label}</th>`);
|
||||
});
|
||||
|
||||
// Two data rows: R1-R2 and R3+
|
||||
function makeRow(rowLabel, getValue) {
|
||||
const tr = $('<tr>').append(`<td>${rowLabel}</td>`);
|
||||
entries.forEach(entry => {
|
||||
const pct = getValue(entry);
|
||||
const td = $('<td>');
|
||||
if (pct !== undefined) {
|
||||
td.text(pct + '%');
|
||||
td.addClass(pct < 50 ? 'bg-success-subtle' : pct < 75 ? 'bg-warning-subtle' : 'bg-danger-subtle');
|
||||
}
|
||||
tr.append(td);
|
||||
});
|
||||
return tr;
|
||||
}
|
||||
|
||||
$('#forecast-blackout-tbody').empty()
|
||||
.append(makeRow('R1-R2', e => e.r1r2))
|
||||
.append(makeRow('R3 or greater', e => e.r3));
|
||||
}
|
||||
|
||||
// Render the DX stats table for the currently selected DE continent
|
||||
function renderDxStats() {
|
||||
if (!dxStatsData) {
|
||||
return;
|
||||
}
|
||||
const deContinent = $('#dxstats-de-continent').val();
|
||||
const deData = dxStatsData[deContinent];
|
||||
if (!deData) {
|
||||
return;
|
||||
}
|
||||
|
||||
const cells = [];
|
||||
Object.entries(deData).forEach(function ([dxContinent, bands]) {
|
||||
Object.entries(bands).forEach(function ([band, count]) {
|
||||
const cell = $('#dxstats-' + dxContinent + '-' + band);
|
||||
cell.text(count);
|
||||
cells.push({cell, count});
|
||||
});
|
||||
});
|
||||
|
||||
const counts = cells.map(function (c) {
|
||||
return c.count;
|
||||
});
|
||||
const min = Math.min(...counts);
|
||||
const max = Math.max(...counts);
|
||||
const range = max - min;
|
||||
cells.forEach(function ({cell, count}) {
|
||||
const t = range > 0 ? (count - min) / range : 0;
|
||||
const cls = t === 0 ? 'bg-danger-subtle' : t < 0.05 ? 'bg-warning-subtle' : 'bg-success-subtle';
|
||||
cell.removeClass('bg-danger-subtle bg-warning-subtle bg-success-subtle').addClass(cls);
|
||||
});
|
||||
}
|
||||
|
||||
// Called when the DE continent select changes
|
||||
function dxStatsContientChanged() {
|
||||
saveSettings();
|
||||
renderDxStats();
|
||||
}
|
||||
|
||||
// Fetch DX stats from the API and render
|
||||
function loadDxStats() {
|
||||
$.getJSON('/api/v1/dxstats', function (jsonData) {
|
||||
dxStatsData = jsonData;
|
||||
renderDxStats();
|
||||
});
|
||||
}
|
||||
|
||||
// Startup
|
||||
$(document).ready(function () {
|
||||
loadSettings();
|
||||
loadSolarConditions();
|
||||
loadDxStats();
|
||||
});
|
||||
@@ -1,10 +1,29 @@
|
||||
// How often to query the server?
|
||||
const REFRESH_INTERVAL_SEC = 60;
|
||||
|
||||
// Colours
|
||||
const MAIDENHEAD_GRID_COLOR_LIGHT = 'rgba(200, 140, 140, 1.0)';
|
||||
const CQ_ZONES_COLOR_LIGHT = 'rgba(140, 200, 140, 1.0)';
|
||||
const ITU_ZONES_COLOR_LIGHT = 'rgba(200, 200, 140, 1.0)';
|
||||
const WAB_WAI_GRID_COLOR_LIGHT = 'rgba(140, 140, 200, 1.0)';
|
||||
const MAIDENHEAD_GRID_COLOR_DARK = 'rgba(120, 60, 60, 1.0)';
|
||||
const CQ_ZONES_COLOR_DARK = 'rgba(60, 120, 60, 1.0)';
|
||||
const ITU_ZONES_COLOR_DARK = 'rgba(120, 120, 60, 1.0)';
|
||||
const WAB_WAI_GRID_COLOR_DARK = 'rgba(60, 60, 120, 1.0)';
|
||||
|
||||
// Map layers
|
||||
var backgroundTileLayer;
|
||||
var markersLayer;
|
||||
var geodesicsLayer;
|
||||
var terminator;
|
||||
var maidenheadGrid;
|
||||
var cqZones;
|
||||
var ituZones;
|
||||
var wabwaiGrid;
|
||||
// Tracks the currently-loaded basemap provider string to avoid unnecessary tile reloads
|
||||
var loadedBasemap;
|
||||
// Tracks whether this is the first display of markers after page load
|
||||
var firstLoad = true;
|
||||
|
||||
// Load spots and populate the map.
|
||||
function loadSpots() {
|
||||
@@ -13,7 +32,9 @@ function loadSpots() {
|
||||
spots = jsonData;
|
||||
// Update map
|
||||
updateMap();
|
||||
terminator.setTime();
|
||||
if ($("#showTerminator")[0].checked) {
|
||||
terminator.setTime();
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
@@ -57,6 +78,15 @@ function updateMap() {
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
// On first load, zoom to the extent of the markers
|
||||
if (firstLoad) {
|
||||
if (markersLayer.getLayers().length >= 2) {
|
||||
var group = new L.featureGroup(markersLayer.getLayers());
|
||||
map.fitBounds(group.getBounds().pad(0.1));
|
||||
}
|
||||
firstLoad = false;
|
||||
}
|
||||
}
|
||||
|
||||
// Get an icon for a spot, based on its band, using PSK Reporter colours, its program etc.
|
||||
@@ -180,10 +210,27 @@ function loadOptions() {
|
||||
// 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();
|
||||
loadMapURLParams();
|
||||
|
||||
// Load settings from settings storage now all the controls are available
|
||||
loadSettings();
|
||||
|
||||
// If no basemap has been explicitly saved and the UI is in dark mode, default to dark Mapnik
|
||||
if (localStorage.getItem("#basemap:value") === null) {
|
||||
if (document.documentElement.getAttribute("data-bs-theme") === "dark") {
|
||||
$("#basemap").val("OpenStreetMap.Mapnik.Dark");
|
||||
}
|
||||
}
|
||||
|
||||
// Apply basemap and overlay settings now that controls have their saved values
|
||||
setBasemap($("#basemap").val());
|
||||
setBasemapOpacity(parseFloat($("#basemapOpacity").val()));
|
||||
enableTerminator($("#showTerminator")[0].checked);
|
||||
enableMaidenheadGrid($("#showMaidenheadGrid")[0].checked);
|
||||
enableCQZones($("#showCQZones")[0].checked);
|
||||
enableITUZones($("#showITUZones")[0].checked);
|
||||
enableWABWAIGrid($("#showWABWAIGrid")[0].checked);
|
||||
|
||||
// Load spots and set up the timer
|
||||
loadSpots();
|
||||
setInterval(loadSpots, REFRESH_INTERVAL_SEC * 1000);
|
||||
@@ -193,34 +240,148 @@ function loadOptions() {
|
||||
// Method called when any display property is changed to reload the map and persist the display settings.
|
||||
function displayUpdated() {
|
||||
updateMap();
|
||||
setBasemap($("#basemap").val());
|
||||
setBasemapOpacity(parseFloat($("#basemapOpacity").val()));
|
||||
enableTerminator($("#showTerminator")[0].checked);
|
||||
enableMaidenheadGrid($("#showMaidenheadGrid")[0].checked);
|
||||
enableCQZones($("#showCQZones")[0].checked);
|
||||
enableITUZones($("#showITUZones")[0].checked);
|
||||
enableWABWAIGrid($("#showWABWAIGrid")[0].checked);
|
||||
saveSettings();
|
||||
}
|
||||
|
||||
// React to toggling/closing panels
|
||||
function toggleFiltersPanel() {
|
||||
// If we are going to show the filters panel, hide the display panel
|
||||
if (!$("#filters-area").is(":visible") && $("#display-area").is(":visible")) {
|
||||
$("#display-area").hide();
|
||||
$("#display-button").button("toggle");
|
||||
// Set the basemap
|
||||
function setBasemap(basemapname) {
|
||||
// Only change if we have to, to avoid a flash of reloading content
|
||||
if (loadedBasemap !== basemapname) {
|
||||
loadedBasemap = basemapname;
|
||||
if (typeof backgroundTileLayer !== 'undefined') {
|
||||
map.removeLayer(backgroundTileLayer);
|
||||
}
|
||||
// OpenStreetMap.Mapnik.Dark is a synthetic variant that uses Mapnik tiles with a CSS filter applied
|
||||
const providerName = basemapname === "OpenStreetMap.Mapnik.Dark" ? "OpenStreetMap.Mapnik" : basemapname;
|
||||
backgroundTileLayer = L.tileLayer.provider(providerName, {
|
||||
opacity: parseFloat($("#basemapOpacity").val()),
|
||||
edgeBufferTiles: 1
|
||||
});
|
||||
backgroundTileLayer.addTo(map);
|
||||
backgroundTileLayer.bringToBack();
|
||||
if (basemapname === "OpenStreetMap.Mapnik.Dark") {
|
||||
var container = backgroundTileLayer.getContainer();
|
||||
if (container) {
|
||||
container.style.filter = 'invert(100%) hue-rotate(180deg) brightness(80%)';
|
||||
}
|
||||
}
|
||||
|
||||
// Identify dark basemaps to ensure we use white text for unselected icons
|
||||
// and change the background colour appropriately
|
||||
const basemapIsDark = basemapname === "CartoDB.DarkMatter" || basemapname === "Esri.WorldImagery" || basemapname === "OpenStreetMap.Mapnik.Dark";
|
||||
$("#map").css('background-color', basemapIsDark ? "black" : "white");
|
||||
|
||||
// Change the colour of the grid and zone overlays to match
|
||||
if (basemapIsDark) {
|
||||
maidenheadGrid.options.color = MAIDENHEAD_GRID_COLOR_DARK;
|
||||
cqZones.options.color = CQ_ZONES_COLOR_DARK;
|
||||
ituZones.options.color = ITU_ZONES_COLOR_DARK;
|
||||
wabwaiGrid.options.color = WAB_WAI_GRID_COLOR_DARK;
|
||||
} else {
|
||||
maidenheadGrid.options.color = MAIDENHEAD_GRID_COLOR_LIGHT;
|
||||
cqZones.options.color = CQ_ZONES_COLOR_LIGHT;
|
||||
ituZones.options.color = ITU_ZONES_COLOR_LIGHT;
|
||||
wabwaiGrid.options.color = WAB_WAI_GRID_COLOR_LIGHT;
|
||||
}
|
||||
|
||||
// Force regenerate overlays in the new colours
|
||||
map.removeLayer(maidenheadGrid);
|
||||
map.removeLayer(cqZones);
|
||||
map.removeLayer(ituZones);
|
||||
map.removeLayer(wabwaiGrid);
|
||||
enableMaidenheadGrid($("#showMaidenheadGrid")[0].checked);
|
||||
enableCQZones($("#showCQZones")[0].checked);
|
||||
enableITUZones($("#showITUZones")[0].checked);
|
||||
enableWABWAIGrid($("#showWABWAIGrid")[0].checked);
|
||||
}
|
||||
$("#filters-area").toggle();
|
||||
}
|
||||
function closeFiltersPanel() {
|
||||
$("#filters-button").button("toggle");
|
||||
$("#filters-area").hide();
|
||||
}
|
||||
|
||||
function toggleDisplayPanel() {
|
||||
// If we are going to show the display panel, hide the filters panel
|
||||
if (!$("#display-area").is(":visible") && $("#filters-area").is(":visible")) {
|
||||
$("#filters-area").hide();
|
||||
$("#filters-button").button("toggle");
|
||||
// Set the basemap opacity
|
||||
function setBasemapOpacity(opacity) {
|
||||
if (typeof backgroundTileLayer !== 'undefined') {
|
||||
backgroundTileLayer.setOpacity(opacity);
|
||||
}
|
||||
$("#display-area").toggle();
|
||||
}
|
||||
function closeDisplayPanel() {
|
||||
$("#display-button").button("toggle");
|
||||
$("#display-area").hide();
|
||||
|
||||
// Shows/hides the terminator/greyline overlay
|
||||
function enableTerminator(show) {
|
||||
if (show) {
|
||||
terminator.setTime();
|
||||
terminator.addTo(map);
|
||||
} else {
|
||||
map.removeLayer(terminator);
|
||||
}
|
||||
}
|
||||
|
||||
// Shows/hides the Maidenhead grid overlay
|
||||
function enableMaidenheadGrid(show) {
|
||||
if (show) {
|
||||
maidenheadGrid.addTo(map);
|
||||
backgroundTileLayer.bringToBack();
|
||||
} else {
|
||||
map.removeLayer(maidenheadGrid);
|
||||
}
|
||||
}
|
||||
|
||||
// Shows/hides the CQ zone overlay
|
||||
function enableCQZones(show) {
|
||||
if (show) {
|
||||
cqZones.addTo(map);
|
||||
backgroundTileLayer.bringToBack();
|
||||
} else {
|
||||
map.removeLayer(cqZones);
|
||||
}
|
||||
}
|
||||
|
||||
// Shows/hides the ITU zone overlay
|
||||
function enableITUZones(show) {
|
||||
if (show) {
|
||||
ituZones.addTo(map);
|
||||
backgroundTileLayer.bringToBack();
|
||||
} else {
|
||||
map.removeLayer(ituZones);
|
||||
}
|
||||
}
|
||||
|
||||
// Shows/hides the WAB/WAI grid overlay
|
||||
function enableWABWAIGrid(show) {
|
||||
if (show) {
|
||||
wabwaiGrid.addTo(map);
|
||||
backgroundTileLayer.bringToBack();
|
||||
} else {
|
||||
map.removeLayer(wabwaiGrid);
|
||||
}
|
||||
}
|
||||
|
||||
// Load map-specific URL parameters for center position and zoom level.
|
||||
// These set Leaflet state directly rather than form controls, so they live here rather than in loadURLParams().
|
||||
// If any parameter is applied, firstLoad is set to false so updateMap() does not override the position.
|
||||
function loadMapURLParams() {
|
||||
let params = new URLSearchParams(document.location.search);
|
||||
let lat = parseFloat(params.get("map-center-lat"));
|
||||
let lon = parseFloat(params.get("map-center-lon"));
|
||||
let zoom = parseFloat(params.get("map-zoom"));
|
||||
|
||||
let hasLatLon = !isNaN(lat) && !isNaN(lon);
|
||||
let hasZoom = !isNaN(zoom);
|
||||
|
||||
if (hasLatLon || hasZoom) {
|
||||
if (hasLatLon && hasZoom) {
|
||||
map.setView([lat, lon], zoom);
|
||||
} else if (hasLatLon) {
|
||||
map.setView([lat, lon], map.getZoom());
|
||||
} else {
|
||||
map.setZoom(zoom);
|
||||
}
|
||||
firstLoad = false;
|
||||
}
|
||||
}
|
||||
|
||||
// Set up the map
|
||||
@@ -233,12 +394,20 @@ function setUpMap() {
|
||||
});
|
||||
|
||||
// Add basemap
|
||||
backgroundTileLayer = L.tileLayer.provider("OpenStreetMap.Mapnik", {
|
||||
opacity: 1,
|
||||
loadedBasemap = $("#basemap").val();
|
||||
const initialProviderName = loadedBasemap === "OpenStreetMap.Mapnik.Dark" ? "OpenStreetMap.Mapnik" : loadedBasemap;
|
||||
backgroundTileLayer = L.tileLayer.provider(initialProviderName, {
|
||||
opacity: parseFloat($("#basemapOpacity").val()),
|
||||
edgeBufferTiles: 1
|
||||
});
|
||||
backgroundTileLayer.addTo(map);
|
||||
backgroundTileLayer.bringToBack();
|
||||
if (loadedBasemap === "OpenStreetMap.Mapnik.Dark") {
|
||||
var container = backgroundTileLayer.getContainer();
|
||||
if (container) {
|
||||
container.style.filter = 'invert(100%) hue-rotate(180deg) brightness(80%)';
|
||||
}
|
||||
}
|
||||
|
||||
// Add marker layer
|
||||
markersLayer = new L.LayerGroup();
|
||||
@@ -248,14 +417,53 @@ function setUpMap() {
|
||||
geodesicsLayer = new L.LayerGroup();
|
||||
geodesicsLayer.addTo(map);
|
||||
|
||||
// Add terminator/greyline
|
||||
// Add terminator/greyline (toggleable)
|
||||
terminator = L.terminator({
|
||||
interactive: false
|
||||
});
|
||||
terminator.setStyle({fillColor: '#00000050'});
|
||||
terminator.addTo(map);
|
||||
if ($("#showTerminator")[0].checked) {
|
||||
terminator.addTo(map);
|
||||
}
|
||||
|
||||
// Display a default view.
|
||||
// Add Maidenhead grid (toggleable)
|
||||
maidenheadGrid = L.maidenhead({
|
||||
color : MAIDENHEAD_GRID_COLOR_LIGHT
|
||||
});
|
||||
if ($("#showMaidenheadGrid")[0].checked) {
|
||||
maidenheadGrid.addTo(map);
|
||||
backgroundTileLayer.bringToBack();
|
||||
}
|
||||
|
||||
// Add CQ zone layer (toggleable)
|
||||
cqZones = L.cqzones({
|
||||
color : CQ_ZONES_COLOR_LIGHT
|
||||
});
|
||||
if ($("#showCQZones")[0].checked) {
|
||||
cqZones.addTo(map);
|
||||
backgroundTileLayer.bringToBack();
|
||||
}
|
||||
|
||||
// Add ITU zone layer (toggleable)
|
||||
ituZones = L.ituzones({
|
||||
color : ITU_ZONES_COLOR_LIGHT
|
||||
});
|
||||
if ($("#showITUZones")[0].checked) {
|
||||
ituZones.addTo(map);
|
||||
backgroundTileLayer.bringToBack();
|
||||
}
|
||||
|
||||
// Add WAB/WAI grid layer (toggleable)
|
||||
wabwaiGrid = L.workedAllBritainIreland({
|
||||
color : WAB_WAI_GRID_COLOR_LIGHT
|
||||
});
|
||||
if ($("#showWABWAIGrid")[0].checked) {
|
||||
wabwaiGrid.addTo(map);
|
||||
backgroundTileLayer.bringToBack();
|
||||
}
|
||||
|
||||
// Display a default view. This will only last until the spots are first loaded, at which point the map will zoom
|
||||
// to the extent of ths spots.
|
||||
map.setView([30, 0], 3);
|
||||
}
|
||||
|
||||
|
||||
@@ -61,6 +61,11 @@ function startSSEConnection() {
|
||||
|
||||
// Add the new spot to table
|
||||
addSpotToTopOfTable(newSpot, true);
|
||||
|
||||
// Ping if we need to
|
||||
if ($("#pingOnNewSpots")[0].checked) {
|
||||
new Audio("/audio/ping.mp3").play();
|
||||
}
|
||||
};
|
||||
|
||||
evtSource.onerror = function(err) {
|
||||
@@ -111,39 +116,39 @@ function updateTable() {
|
||||
let table = $("#table");
|
||||
table.find('thead tr').empty();
|
||||
if (showTime) {
|
||||
table.find('thead tr').append(`<th>${useLocalTime ? "Local" : "UTC"}</th>`);
|
||||
table.find('thead tr').append(`<th class="bg-primary-subtle">${useLocalTime ? "Local" : "UTC"}</th>`);
|
||||
}
|
||||
if (showDX) {
|
||||
table.find('thead tr').append(`<th>DX</th>`);
|
||||
table.find('thead tr').append(`<th class="bg-primary-subtle">DX</th>`);
|
||||
}
|
||||
if (showFreq) {
|
||||
table.find('thead tr').append(`<th>Freq<span class='hideonmobile'>uency</span></th>`);
|
||||
table.find('thead tr').append(`<th class="bg-primary-subtle">Freq<span class='bg-primary-subtle hideonmobile'>uency</span></th>`);
|
||||
}
|
||||
if (showMode) {
|
||||
table.find('thead tr').append(`<th>Mode</th>`);
|
||||
table.find('thead tr').append(`<th class="bg-primary-subtle">Mode</th>`);
|
||||
}
|
||||
if (showComment) {
|
||||
table.find('thead tr').append(`<th class='hideonmobile'>Comment</th>`);
|
||||
table.find('thead tr').append(`<th class='bg-primary-subtle hideonmobile'>Comment</th>`);
|
||||
}
|
||||
if (showBearing) {
|
||||
table.find('thead tr').append(`<th class='hideonmobile'>Bearing</th>`);
|
||||
table.find('thead tr').append(`<th class='bg-primary-subtle hideonmobile'>Bearing</th>`);
|
||||
}
|
||||
if (showType) {
|
||||
table.find('thead tr').append(`<th class='hideonmobile'>Type</th>`);
|
||||
table.find('thead tr').append(`<th class='bg-primary-subtle hideonmobile'>Type</th>`);
|
||||
}
|
||||
if (showRef) {
|
||||
table.find('thead tr').append(`<th class='hideonmobile'>Ref.</th>`);
|
||||
table.find('thead tr').append(`<th class='bg-primary-subtle hideonmobile'>Ref.</th>`);
|
||||
}
|
||||
if (showDE) {
|
||||
table.find('thead tr').append(`<th class='hideonmobile'>DE</th>`);
|
||||
table.find('thead tr').append(`<th class='bg-primary-subtle hideonmobile'>DE</th>`);
|
||||
}
|
||||
if (showWorkedCheckbox) {
|
||||
table.find('thead tr').append(`<th class='hideonmobile'></th>`);
|
||||
table.find('thead tr').append(`<th class='bg-primary-subtle hideonmobile'></th>`);
|
||||
}
|
||||
|
||||
table.find('tbody').empty();
|
||||
if (spots.length == 0) {
|
||||
table.find('tbody').append('<tr class="table-danger"><td colspan="100" style="text-align:center;">No spots match your filters.</td></tr>');
|
||||
table.find('tbody').append('<tr class="bg-danger-subtle"><td colspan="100" style="text-align:center;">No spots match your filters.</td></tr>');
|
||||
}
|
||||
|
||||
// We are regenerating the entire table not just adding a new row, so reset the row counter
|
||||
@@ -462,33 +467,6 @@ function userGridUpdated() {
|
||||
saveSettings();
|
||||
}
|
||||
|
||||
// React to toggling/closing panels
|
||||
function toggleFiltersPanel() {
|
||||
// If we are going to show the filters panel, hide the display and add spot panels
|
||||
if (!$("#filters-area").is(":visible") && $("#display-area").is(":visible")) {
|
||||
$("#display-area").hide();
|
||||
$("#display-button").button("toggle");
|
||||
}
|
||||
$("#filters-area").toggle();
|
||||
}
|
||||
function closeFiltersPanel() {
|
||||
$("#filters-button").button("toggle");
|
||||
$("#filters-area").hide();
|
||||
}
|
||||
|
||||
function toggleDisplayPanel() {
|
||||
// If we are going to show the display panel, hide the filters and add spot panels
|
||||
if (!$("#display-area").is(":visible") && $("#filters-area").is(":visible")) {
|
||||
$("#filters-area").hide();
|
||||
$("#filters-button").button("toggle");
|
||||
}
|
||||
$("#display-area").toggle();
|
||||
}
|
||||
function closeDisplayPanel() {
|
||||
$("#display-button").button("toggle");
|
||||
$("#display-area").hide();
|
||||
}
|
||||
|
||||
// Display the intro box, unless the user has already dismissed it once.
|
||||
function displayIntroBox() {
|
||||
if (localStorage.getItem("intro-box-dismissed") == null) {
|
||||
|
||||
@@ -6,27 +6,26 @@ var spots = []
|
||||
// to localStorage.
|
||||
let worked = []
|
||||
|
||||
// Dynamically add CSS code for the band toggle buttons to be in the appropriate colour.
|
||||
// Dynamically add CSS code for the band checkboxes to show in the appropriate colour.
|
||||
// Some band names contain decimal points which are not allowed in CSS classes, so we text-replace them to "p".
|
||||
function addBandToggleColourCSS(band_options) {
|
||||
var $style = $('<style>');
|
||||
band_options.forEach(o => {
|
||||
var domSafeName = o["name"].replace(/^[^A-Za-z0-9]+|[^\w]+/gi, "");
|
||||
$style.append(`#filter-button-label-band-${domSafeName} { border-color: ${bandToColor(o['name'])}; color: var(--bs-secondary);}`);
|
||||
$style.append(`.btn-check:checked + #filter-button-label-band-${domSafeName} { background-color: ${bandToColor(o['name'])}; color: ${bandToContrastColor(o['name'])};}`);
|
||||
$style.append(`#filter-button-label-band-${domSafeName} { padding-left: 0.3em; border-left: 5px solid ${bandToColor(o['name'])};}`);
|
||||
});
|
||||
$('html > head').append($style);
|
||||
}
|
||||
|
||||
// Generate bands filter card. This one is a special case.
|
||||
function generateBandsMultiToggleFilterCard(band_options) {
|
||||
// Create a button for each option
|
||||
var $grid = $('<div class="row row-cols-3 row-cols-md-2 row-cols-lg-3 row-cols-xxl-4 g-1 mb-1">');
|
||||
band_options.forEach(o => {
|
||||
var domSafeName = o["name"].replace(/^[^A-Za-z0-9]+|[^\w]+/gi, "");
|
||||
$("#band-options").append(`<input type="checkbox" class="btn-check filter-button-band storeable-checkbox" name="options" id="filter-button-band-${domSafeName}" value="${o['name']}" autocomplete="off" onClick="filtersUpdated()" checked><label class="btn btn-outline-secondary" id="filter-button-label-band-${domSafeName}" for="filter-button-band-${domSafeName}">${o['name']}</label> `);
|
||||
$grid.append(`<div class="col"><div class="form-check"><input type="checkbox" class="form-check-input filter-button-band storeable-checkbox" id="filter-button-band-${domSafeName}" value="${o['name']}" autocomplete="off" onClick="filtersUpdated()" checked> <label class="form-check-label" id="filter-button-label-band-${domSafeName}" for="filter-button-band-${domSafeName}">${o['name']}</label></div></div>`);
|
||||
});
|
||||
// Create All/None/Ham HF buttons
|
||||
$("#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> <button id="filter-button-band-none" type="button" class="btn btn-outline-secondary" onclick="setHamHFBandToggles();">Ham HF</button></span>`);
|
||||
$("#band-options").append($grid);
|
||||
$("#band-options").append(`<div class="mt-1"><a href="#" onclick="toggleFilterButtons('band', true); return false;">All</a> <a href="#" onclick="toggleFilterButtons('band', false); return false;">None</a> <a href="#" onclick="setHamHFBandToggles(); return false;">Ham HF only</a></div>`);
|
||||
}
|
||||
|
||||
// Set the band toggles so that only the amateur radio HF bands are selected. This includes 160m and 6m because that's
|
||||
@@ -41,72 +40,58 @@ function setHamHFBandToggles() {
|
||||
|
||||
// Generate SIGs filter card. This one is also a special case.
|
||||
function generateSIGsMultiToggleFilterCard(sig_options) {
|
||||
// Create a button for each option
|
||||
var $grid = $('<div class="row row-cols-2 row-cols-xxl-3 g-1 mb-1">');
|
||||
sig_options.forEach(o => {
|
||||
var domSafeName = o["name"].replace(/^[^A-Za-z0-9]+|[^\w]+/gi, "");
|
||||
$("#sig-options").append(`<input type="checkbox" class="btn-check filter-button-sig storeable-checkbox" name="options" id="filter-button-sig-${domSafeName}" value="${o['name']}" autocomplete="off" onClick="filtersUpdated()" checked><label class="btn btn-outline-success" id="filter-button-label-sig-${domSafeName}" for="filter-button-sig-${domSafeName}" title="${o['description']}"><i class="fa-solid ${sigToIcon(o['name'], 'fa-tower-cell')}"></i> ${o['name']}</label> `);
|
||||
$grid.append(`<div class="col"><div class="form-check"><input type="checkbox" class="form-check-input filter-button-sig storeable-checkbox" id="filter-button-sig-${domSafeName}" value="${o['name']}" autocomplete="off" onClick="filtersUpdated()" checked><label class="form-check-label" id="filter-button-label-sig-${domSafeName}" for="filter-button-sig-${domSafeName}" title="${o['description']}"><i class="fa-solid ${sigToIcon(o['name'], 'fa-tower-cell')}"></i> ${o['name']}</label></div></div>`);
|
||||
});
|
||||
// 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-success" 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>`);
|
||||
// Bonus "NO_SIG" / "General DX" option
|
||||
$grid.append(`<div class="w-100"><div class="form-check"><input type="checkbox" class="form-check-input filter-button-sig storeable-checkbox" id="filter-button-sig-NO_SIG" value="NO_SIG" autocomplete="off" onClick="filtersUpdated()" checked><label class="form-check-label" id="filter-button-label-sig-NO_SIG" for="filter-button-sig-NO_SIG"><i class="fa-solid fa-tower-cell"></i> General DX</label></div></div>`);
|
||||
$("#sig-options").append($grid);
|
||||
$("#sig-options").append(`<div class="mt-1"><a href="#" onclick="toggleFilterButtons('sig', true); return false;">All</a> <a href="#" onclick="toggleFilterButtons('sig', false); return false;">None</a></div>`);
|
||||
}
|
||||
|
||||
// Generate modes filter card. This one is also a special case.
|
||||
function generateModesMultiToggleFilterCard(mode_options) {
|
||||
// Create a button for each option
|
||||
var $grid = $('<div class="row row-cols-3 row-cols-md-2 row-cols-lg-3 g-1 mb-1">');
|
||||
mode_options.forEach(o => {
|
||||
var domSafeName = o.replace(/^[^A-Za-z0-9]+|[^\w]+/gi, "");
|
||||
$("#mode-options").append(`<input type="checkbox" class="btn-check filter-button-mode storeable-checkbox" name="options" id="filter-button-mode-${domSafeName}" value="${o}" autocomplete="off" onClick="filtersUpdated()" checked><label class="btn btn-outline-success" id="filter-button-label-mode-${domSafeName}" for="filter-button-mode-${domSafeName}">${o}</label> `);
|
||||
$grid.append(`<div class="col"><div class="form-check"><input type="checkbox" class="form-check-input filter-button-mode storeable-checkbox" id="filter-button-mode-${domSafeName}" value="${o}" autocomplete="off" onClick="filtersUpdated()" checked><label class="form-check-label" id="filter-button-label-mode-${domSafeName}" for="filter-button-mode-${domSafeName}">${o}</label></div></div>`);
|
||||
});
|
||||
// Create All/None buttons
|
||||
$("#mode-options").append(` <span style="display: inline-block"><button id="filter-button-mode-all" type="button" class="btn btn-outline-secondary" onclick="toggleFilterButtons('mode', true);">All</button> <button id="filter-button-mode-none" type="button" class="btn btn-outline-secondary" onclick="toggleFilterButtons('mode', false);">None</button></span>`);
|
||||
// Create category buttons
|
||||
$("#mode-options").append(` <button id="filter-button-mode-av" type="button" class="btn btn-outline-secondary" onclick="toggleAnalogVoiceModeToggles();">Analog Voice</button> <button id="filter-button-mode-dv" type="button" class="btn btn-outline-secondary" onclick="toggleDigitalVoiceModeToggles();">Digital Voice</button> <button id="filter-button-mode-digi" type="button" class="btn btn-outline-secondary" onclick="toggleDigiModeToggles();">Digimodes</button></span>`);
|
||||
$("#mode-options").append($grid);
|
||||
$("#mode-options").append(`<div class="mt-1"><a href="#" onclick="toggleFilterButtons('mode', true); return false;">All</a> <a href="#" onclick="toggleFilterButtons('mode', false); return false;">None</a> <a href="#" onclick="setVoiceModeToggles(); return false;">Voice only</a> <a href="#" onclick="setDigiModeToggles(); return false;">Digimodes only</a></div>`);
|
||||
}
|
||||
|
||||
// Toggle the mode toggles that relate to Analog Voice.
|
||||
function toggleAnalogVoiceModeToggles() {
|
||||
toggleToggles("mode", ["PHONE", "SSB", "LSB", "USB", "AM", "FM"]);
|
||||
}
|
||||
|
||||
// Toggle the mode toggles that relate to Digital Voice.
|
||||
function toggleDigitalVoiceModeToggles() {
|
||||
toggleToggles("mode", ["DV", "DMR", "DSTAR", "C4FM", "M17"]);
|
||||
}
|
||||
|
||||
// Toggle the mode toggles that relate to Digimodes.
|
||||
function toggleDigiModeToggles() {
|
||||
toggleToggles("mode", ["DATA", "FT8", "FT4", "RTTY", "SSTV", "JS8", "HELL", "PSK", "OLIVIA", "PKT", "MSK144"]);
|
||||
}
|
||||
|
||||
// Toggle the a set of toggles of the given type (e.g. "mode") that match the given values (e.g. ["SSB", "AM", "FM"]).
|
||||
function toggleToggles(type, values) {
|
||||
let toggle = null;
|
||||
$(".filter-button-" + type).each(function() {
|
||||
console.log($(this));
|
||||
if (values.includes($(this).val().replace("filter-button-" + type, ""))) {
|
||||
if (toggle == null) {
|
||||
toggle = !$(this).prop('checked');
|
||||
}
|
||||
$(this).prop('checked', toggle);
|
||||
}
|
||||
// Set the mode toggles that relate to Analog Voice.
|
||||
function setVoiceModeToggles() {
|
||||
const modes = ["PHONE", "SSB", "LSB", "USB", "AM", "FM", "DV", "DMR", "DSTAR", "C4FM", "M17"];
|
||||
$(".filter-button-mode").each(function() {
|
||||
$(this).prop('checked', modes.includes($(this).val().replace("filter-button-mode-", "")));
|
||||
});
|
||||
filtersUpdated();
|
||||
}
|
||||
|
||||
// Generate Sources filter card. This one is a minor special case as we create the buttons in the normal way, but then
|
||||
// Set the mode toggles that relate to Digimodes.
|
||||
function setDigiModeToggles() {
|
||||
const modes = ["DATA", "FT8", "FT4", "RTTY", "SSTV", "JS8", "HELL", "PSK", "OLIVIA", "PKT", "MSK144"];
|
||||
$(".filter-button-mode").each(function() {
|
||||
$(this).prop('checked', modes.includes($(this).val().replace("filter-button-mode-", "")));
|
||||
});
|
||||
filtersUpdated();
|
||||
}
|
||||
|
||||
// Generate Sources filter card. This one is a minor special case as we create the checkboxes in the normal way, but
|
||||
// set which ones are enabled by default based on config rather than having them all enabled by default. We also sanitise
|
||||
// names here for HTML elements.
|
||||
function generateSourcesMultiToggleFilterCard(source_options, sources_enabled_by_default) {
|
||||
// Create a button for each option
|
||||
var $grid = $('<div class="row row-cols-2 row-cols-xxl-3 g-1 mb-1">');
|
||||
source_options.forEach(o => {
|
||||
var enable = sources_enabled_by_default.includes(o);
|
||||
var domSafeName = o.replace(/^[^A-Za-z0-9]+|[^\w]+/gi, "");
|
||||
$("#source-options").append(`<input type="checkbox" class="btn-check filter-button-source storeable-checkbox" name="options" id="filter-button-source-${domSafeName}" value="${o}" autocomplete="off" onClick="filtersUpdated()" ${enable ? "checked" : ""}><label class="btn btn-outline-success" for="filter-button-source-${domSafeName}">${o}</label> `);
|
||||
$grid.append(`<div class="col"><div class="form-check"><input type="checkbox" class="form-check-input filter-button-source storeable-checkbox" id="filter-button-source-${domSafeName}" value="${o}" autocomplete="off" onClick="filtersUpdated()" ${enable ? "checked" : ""}><label class="form-check-label" for="filter-button-source-${domSafeName}">${o}</label></div></div>`);
|
||||
});
|
||||
// Create All/None buttons
|
||||
$("#source-options").append(` <span style="display: inline-block"><button id="filter-button-source-all" type="button" class="btn btn-outline-secondary" onclick="toggleFilterButtons('source', true);">All</button> <button id="filter-button-source-none" type="button" class="btn btn-outline-secondary" onclick="toggleFilterButtons('source', false);">None</button></span>`);
|
||||
$("#source-options").append($grid);
|
||||
$("#source-options").append(`<div class="mt-1"><a href="#" onclick="toggleFilterButtons('source', true); return false;">All</a> <a href="#" onclick="toggleFilterButtons('source', false); return false;">None</a></div>`);
|
||||
}
|
||||
|
||||
// Method called when any filter is changed to reload the spots and persist the filter settings.
|
||||
|
||||
@@ -1,52 +1,50 @@
|
||||
// Load server status
|
||||
function loadStatus() {
|
||||
$.getJSON('/api/v1/status', function(jsonData) {
|
||||
$("#status-container").empty();
|
||||
$("#status-container").append(generateStatusCard("Server Information", [
|
||||
`Software Version: ${jsonData["software-version"]}`,
|
||||
`Server Owner Callsign: ${jsonData["server-owner-callsign"]}`,
|
||||
`Server up since: ${moment().subtract(jsonData["uptime"], 'seconds').fromNow()}`,
|
||||
`Memory Use: ${jsonData["mem_use_mb"]} MB`,
|
||||
`Total Spots: ${jsonData["num_spots"]}`,
|
||||
`Total Alerts: ${jsonData["num_alerts"]}`
|
||||
]));
|
||||
$("#status-container").append(generateStatusCard("Web Server", [
|
||||
`Status: ${jsonData["webserver"]["status"]}`,
|
||||
`Last API Access: ${moment.unix(jsonData["webserver"]["last_api_access"]).utc().fromNow()}`,
|
||||
`Last Page Access: ${moment.unix(jsonData["webserver"]["last_page_access"]).utc().fromNow()}`
|
||||
]));
|
||||
$("#status-container").append(generateStatusCard("Cleanup Service", [
|
||||
`Status: ${jsonData["cleanup"]["status"]}`,
|
||||
`Last Ran: ${moment.unix(jsonData["cleanup"]["last_ran"]).utc().fromNow()}`
|
||||
]));
|
||||
$("#software-version").text(jsonData["software-version"]);
|
||||
$("#server-owner-callsign").text(jsonData["server-owner-callsign"]);
|
||||
$("#up-since").text(moment().subtract(jsonData["uptime"], 'seconds').fromNow());
|
||||
$("#memory-use").text(jsonData["mem_use_mb"] + " MB");
|
||||
$("#total-spots").text(jsonData["num_spots"]);
|
||||
$("#total-alerts").text(jsonData["num_alerts"]);
|
||||
|
||||
$("#web-server-status").text(jsonData["webserver"]["status"]);
|
||||
$("#web-server-last-api").text(moment.unix(jsonData["webserver"]["last_api_access"]).utc().fromNow());
|
||||
$("#web-server-last-page").text(moment.unix(jsonData["webserver"]["last_page_access"]).utc().fromNow());
|
||||
|
||||
$("#cleanup-status").text(jsonData["cleanup"]["status"]);
|
||||
$("#cleanup-last-ran").text(moment.unix(jsonData["cleanup"]["last_ran"]).utc().fromNow());
|
||||
|
||||
jsonData["spot_providers"].forEach(p => {
|
||||
$("#status-container").append(generateStatusCard("Spot Provider: " + p["name"], [
|
||||
`Status: ${p["status"]}`,
|
||||
`Last Updated: ${(p["enabled"] && p["last_updated"] > 0) ? moment.unix(p["last_updated"]).utc().fromNow() : "N/A"}`,
|
||||
`Latest Spot: ${(p["enabled"] && p["last_spot"] > 0) ? moment.unix(p["last_spot"]).utc().fromNow() : "N/A"}`
|
||||
]));
|
||||
$("#spot-providers-status-container").append(`
|
||||
<div class="row row-cols-1 row-cols-md-4 g-4 mb-2">
|
||||
<div class="col"><strong>${p["name"]}</strong></div>
|
||||
<div class="col">Status: ${p["status"]}</div>
|
||||
<div class="col">Last updated: ${(p["enabled"] && p["last_updated"] > 0) ? moment.unix(p["last_updated"]).utc().fromNow() : "N/A"}</div>
|
||||
<div class="col">Latest spot: ${(p["enabled"] && p["last_spot"] > 0) ? moment.unix(p["last_spot"]).utc().fromNow() : "N/A"}</div>
|
||||
</div>`);
|
||||
});
|
||||
|
||||
jsonData["alert_providers"].forEach(p => {
|
||||
$("#status-container").append(generateStatusCard("Alert Provider: " + p["name"], [
|
||||
`Status: ${p["status"]}`,
|
||||
`Last Updated: ${(p["enabled"] && p["last_updated"] > 0) ? moment.unix(p["last_updated"]).utc().fromNow() : "N/A"}`
|
||||
]));
|
||||
$("#alert-providers-status-container").append(`
|
||||
<div class="row row-cols-1 row-cols-md-4 g-4 mb-2">
|
||||
<div class="col"><strong>${p["name"]}</strong></div>
|
||||
<div class="col">Status: ${p["status"]}</div>
|
||||
<div class="col">Last updated: ${(p["enabled"] && p["last_updated"] > 0) ? moment.unix(p["last_updated"]).utc().fromNow() : "N/A"}</div>
|
||||
</div>`);
|
||||
});
|
||||
|
||||
jsonData["solar_condition_providers"].forEach(p => {
|
||||
$("#condition-providers-status-container").append(`
|
||||
<div class="row row-cols-1 row-cols-md-4 g-4 mb-2">
|
||||
<div class="col"><strong>${p["name"]}</strong></div>
|
||||
<div class="col">Status: ${p["status"]}</div>
|
||||
<div class="col">Last updated: ${(p["enabled"] && p["last_updated"] > 0) ? moment.unix(p["last_updated"]).utc().fromNow() : "N/A"}</div>
|
||||
</div>`);
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
// Generate a status card
|
||||
function generateStatusCard(title, textLines) {
|
||||
let $col = $("<div class='col'>");
|
||||
let $card = $("<div class='card'>");
|
||||
let $card_body = $("<div class='card-body'>");
|
||||
$card_body.append(`<h5 class='card-title'>${title}</h5>`);
|
||||
$card_body.append(`<p class='card-text'>${textLines.join("<br/>")}</p>`);
|
||||
$card.append($card_body);
|
||||
$col.append($card);
|
||||
return $col;
|
||||
}
|
||||
|
||||
// Startup
|
||||
$(document).ready(function() {
|
||||
loadStatus();
|
||||
|
||||
Reference in New Issue
Block a user