Compare commits

..
13 Commits
Author SHA1 Message Date
Ian Renton ba56acd7ac Fix a bug where solar conditions and status data were not properly persisted to disk between restarts. Release v2.0.5 2026-08-29 09:17:24 +01:00
Ian Renton 8451ca2727 Release v2.0.4 2026-08-29 08:40:11 +01:00
Ian Renton 7ae77e9fb2 Encourage cluster spot sources to simply be named "Cluster" as the specific node shouldn't matter 2026-08-29 08:37:21 +01:00
Ian Renton c3a360e691 Consistency about whether "GMA Islands" is plural or not; widen columns on SIG list so it doesn't wrap 2026-08-29 08:34:04 +01:00
Ian Renton 2a4190ec4a Fix GIRO data source. Release v2.0.3 2026-08-28 21:47:25 +01:00
Ian Renton c71c0e9d75 Release v2.0.2 2026-08-28 16:10:02 +01:00
Ian Renton f2ef6e93fa Merge remote-tracking branch 'origin/main'
# Conflicts:
#	core/constants.py
#	pyproject.toml
#	templates/add_spot.html
#	templates/alerts.html
#	templates/bands.html
#	templates/base.html
#	templates/conditions.html
#	templates/map.html
#	templates/spots.html
#	templates/status.html
2026-08-28 15:46:24 +01:00
Ian Renton d63897f8ad Provide CartoDB API key 2026-08-28 15:43:52 +01:00
Ian Renton 02e2730e47 v2.0.1 release 2026-08-28 07:59:10 +01:00
Ian Renton b2719d2cd6 Fix a bug where the default maximum spot age was not being applied on first access of the web UI 2026-08-28 07:52:15 +01:00
ian b9268dd4ca Update pyproject.toml 2026-08-25 20:42:07 +00:00
ian 0cce38b0ab Update core/constants.py 2026-08-25 20:41:53 +00:00
ian 5a77248dbc Fix a bug in calling the V1 add spot endpoint 2026-08-25 20:41:12 +00:00
26 changed files with 118 additions and 75 deletions
+1 -1
View File
@@ -2,7 +2,7 @@
<project version="4"> <project version="4">
<component name="ProjectModuleManager"> <component name="ProjectModuleManager">
<modules> <modules>
<module fileurl="file://$PROJECT_DIR$/.idea/Spothole.iml" filepath="$PROJECT_DIR$/.idea/Spothole.iml" /> <module fileurl="file://$PROJECT_DIR$/.idea/spothole.iml" filepath="$PROJECT_DIR$/.idea/spothole.iml" />
</modules> </modules>
</component> </component>
</project> </project>
+1 -1
View File
@@ -1,6 +1,6 @@
<component name="ProjectRunConfigurationManager"> <component name="ProjectRunConfigurationManager">
<configuration default="false" name="Run" type="PythonConfigurationType" factoryName="Python"> <configuration default="false" name="Run" type="PythonConfigurationType" factoryName="Python">
<module name="Spothole" /> <module name="spothole" />
<option name="ENV_FILES" value="" /> <option name="ENV_FILES" value="" />
<option name="INTERPRETER_OPTIONS" value="" /> <option name="INTERPRETER_OPTIONS" value="" />
<option name="PARENT_ENVS" value="true" /> <option name="PARENT_ENVS" value="true" />
View File
+4 -2
View File
@@ -68,7 +68,7 @@ spot_providers:
- class: "DXCluster" - class: "DXCluster"
# Clusters have a "name" property in case you want to say which cluster node the data is from, or connect to # Clusters have a "name" property in case you want to say which cluster node the data is from, or connect to
# several clusters and name them separately. The name will be used as the "source" field for the spots received. # several clusters and name them separately. The name will be used as the "source" field for the spots received.
name: "HRD Cluster" name: "Cluster"
enabled: true enabled: true
host: "hrd.wa9pie.net" host: "hrd.wa9pie.net"
port: 8000 port: 8000
@@ -84,7 +84,7 @@ spot_providers:
allow_rbn_spots: false allow_rbn_spots: false
- class: "DXCluster" - class: "DXCluster"
name: "W3LPL Cluster" name: "Cluster"
enabled: false enabled: false
host: "w3lpl.net" host: "w3lpl.net"
port: 7373 port: 7373
@@ -346,3 +346,5 @@ web_ui_options:
# is designed for a "donate/support the server" type button, though you are free to do whatever you want with it. # is designed for a "donate/support the server" type button, though you are free to do whatever you want with it.
# As the server owner you are responsible for the safe usage of this option! # As the server owner you are responsible for the safe usage of this option!
support_button_html: "" support_button_html: ""
# CartoDB API key. Required to use the CartoDB layers without watermark. Get one from https://carto.com/basemaps/apikey/.
cartodb_api_key: ""
+2 -2
View File
@@ -3,7 +3,7 @@ from data.band import Band
from data.sig import SIG from data.sig import SIG
# General software # General software
SOFTWARE_VERSION = "2.0" SOFTWARE_VERSION = "2.0.5"
# HTTP headers used for spot providers that use HTTP # HTTP headers used for spot providers that use HTTP
HTTP_HEADERS = {"User-Agent": f"Spothole v{SOFTWARE_VERSION} (operated by {SERVER_OWNER_CALLSIGN})"} HTTP_HEADERS = {"User-Agent": f"Spothole v{SOFTWARE_VERSION} (operated by {SERVER_OWNER_CALLSIGN})"}
@@ -54,7 +54,7 @@ SIGS = [
ref_regex=r"[A-Z]{2}\-\d{3}", ref_regex=r"[A-Z]{2}\-\d{3}",
), ),
SIG( SIG(
name="GMA Island", name="GMA Islands",
comment_names=[], comment_names=[],
description="Global Mountain Activity - Islands", description="Global Mountain Activity - Islands",
ref_regex=r"(([A-Z]{2}\-\d{3})|([A-Z0-9]{1,3}\/[A-Z]{2}\-\d{3}))", ref_regex=r"(([A-Z]{2}\-\d{3})|([A-Z0-9]{1,3}\/[A-Z]{2}\-\d{3}))",
+10 -19
View File
@@ -6,6 +6,7 @@ import diskcache
from core.config import MAX_ALERT_AGE, MAX_SPOT_AGE from core.config import MAX_ALERT_AGE, MAX_SPOT_AGE
from core.live_data_cache import LiveDataCache from core.live_data_cache import LiveDataCache
from core.single_object_data_cache import SingleObjectDataCache
from data.solar_conditions import SolarConditions from data.solar_conditions import SolarConditions
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
@@ -34,10 +35,8 @@ class DataStore:
self.dxcc_data = None self.dxcc_data = None
self.dxcc_lookup_by_call_regex = [] self.dxcc_lookup_by_call_regex = []
self.sigrefs = None self.sigrefs = None
self.status_data = {} self.status = None
self._status = None self.solar_conditions = None
self.solar_conditions = {}
self._solar = None
# ITU/CQ zone GeoJSON data is only ever loaded statically from a local file so these don't even need to be # ITU/CQ zone GeoJSON data is only ever loaded statically from a local file so these don't even need to be
# caches, they can just be straight objects # caches, they can just be straight objects
self.cq_zone_data = None self.cq_zone_data = None
@@ -46,16 +45,11 @@ class DataStore:
def setup(self): def setup(self):
Path(CACHE_DIR).mkdir(parents=True, exist_ok=True) Path(CACHE_DIR).mkdir(parents=True, exist_ok=True)
# Standard disk cache for solar data and status data, but each cache contains only a single object which we # For solar data and status data, we use a wrapper around disk cache where each cache contains only a single
# expose to the wider application # object exposed to the wider application, and provides a store() method for callers to notify diskcache that
self._solar = diskcache.Cache(f"{CACHE_DIR}solar") # the object has changed and needs to be re-cached.
if "solar_conditions" not in self._solar: self.solar_conditions = SingleObjectDataCache(f"{CACHE_DIR}solar", SolarConditions())
self._solar.add("solar_conditions", SolarConditions()) self.status = SingleObjectDataCache(f"{CACHE_DIR}status", {})
self.solar_conditions = self._solar.get("solar_conditions")
self._status = diskcache.Cache(f"{CACHE_DIR}status")
if "status_data" not in self._status:
self._status.add("status_data", {})
self.status_data = self._status.get("status_data")
# Standard disk cache for static reference and SIG ref data. Separate provider threads will repopulate these on # Standard disk cache for static reference and SIG ref data. Separate provider threads will repopulate these on
# a regular basis but there's no need for a TTL since old data is better than no data. # a regular basis but there's no need for a TTL since old data is better than no data.
@@ -116,13 +110,10 @@ class DataStore:
self.dxcc_lookup_by_call_regex.append((re.compile(entry["prefixRegex"]), entry["entityCode"])) self.dxcc_lookup_by_call_regex.append((re.compile(entry["prefixRegex"]), entry["entityCode"]))
def close(self): def close(self):
self.spots.save_snapshot()
self.alerts.save_snapshot()
self.spots.close() self.spots.close()
self.alerts.close() self.alerts.close()
self._solar.close() self.solar.close()
self._status.close() self.status.close()
self.dxcc_data.close() self.dxcc_data.close()
self.sigrefs.close() self.sigrefs.close()
self.callsign_data_countryfiles.close() self.callsign_data_countryfiles.close()
+39
View File
@@ -0,0 +1,39 @@
import logging
import threading
import diskcache
logger = logging.getLogger(__name__)
class SingleObjectDataCache:
"""Cache for status and solar conditions. This uses DiskCache, but unlike the standard DiskCache users like SIG and
callsign lookup handlers, status and solar conditions are persisted as a single object. If we just load the object
from DiskCache and modify it, DiskCache doesn't know that it's been updated and needs re-caching, so we provide a
store() method that any functions updating the object can call afterwards."""
def __init__(self, cache_dir, object_if_empty):
"""Initialize a SingleObjectDataCache. Provide the directory to load the cache from and save it to. If the cache
is empty, the provided object_if_empty parameter will be used to initialise it."""
self._lock = threading.Lock()
self._cache = diskcache.Cache(cache_dir)
# This cache stores a single object, doesn't matter what it's called so "object" will do
if "object" not in self._cache:
self._cache.add("object", object_if_empty)
self._obj = self._cache.get("object")
def get(self):
"""Get the data object. This can then be manipulated as necessary across multiple threads. Any function
modifying the object must remember to call store() afterwards."""
return self._obj
def store(self):
"""Store the updated object in the cache. Any function modifying the object must remember to call this
afterwards."""
with self._lock:
self._cache.set("object", self._obj)
def close(self):
self.store()
self._cache.close()
+16 -14
View File
@@ -25,8 +25,9 @@ class StatusReporter:
self._stop_event = Event() self._stop_event = Event()
self._startup_time = datetime.now(pytz.UTC) self._startup_time = datetime.now(pytz.UTC)
DATA_STORE.status_data["software_version"] = SOFTWARE_VERSION DATA_STORE.status.get()["software_version"] = SOFTWARE_VERSION
DATA_STORE.status_data["server_owner_callsign"] = SERVER_OWNER_CALLSIGN DATA_STORE.status.get()["server_owner_callsign"] = SERVER_OWNER_CALLSIGN
DATA_STORE.status.store()
def start(self): def start(self):
"""Start the reporter thread""" """Start the reporter thread"""
@@ -50,11 +51,11 @@ class StatusReporter:
def _report(self): def _report(self):
"""Write status information""" """Write status information"""
DATA_STORE.status_data["uptime"] = (datetime.now(pytz.UTC) - self._startup_time).total_seconds() DATA_STORE.status.get()["uptime"] = (datetime.now(pytz.UTC) - self._startup_time).total_seconds()
DATA_STORE.status_data["mem_use_mb"] = round(psutil.Process(os.getpid()).memory_info().rss / (1024 * 1024), 3) DATA_STORE.status.get()["mem_use_mb"] = round(psutil.Process(os.getpid()).memory_info().rss / (1024 * 1024), 3)
DATA_STORE.status_data["num_spots"] = len(DATA_STORE.spots.values()) DATA_STORE.status.get()["num_spots"] = len(DATA_STORE.spots.values())
DATA_STORE.status_data["num_alerts"] = len(DATA_STORE.alerts.values()) DATA_STORE.status.get()["num_alerts"] = len(DATA_STORE.alerts.values())
DATA_STORE.status_data["spot_providers"] = [ DATA_STORE.status.get()["spot_providers"] = [
{ {
"name": p.name, "name": p.name,
"enabled": p.enabled, "enabled": p.enabled,
@@ -69,7 +70,7 @@ class StatusReporter:
} }
for p in DATA_PROVIDERS.spot_providers for p in DATA_PROVIDERS.spot_providers
] ]
DATA_STORE.status_data["alert_providers"] = [ DATA_STORE.status.get()["alert_providers"] = [
{ {
"name": p.name, "name": p.name,
"enabled": p.enabled, "enabled": p.enabled,
@@ -80,7 +81,7 @@ class StatusReporter:
} }
for p in DATA_PROVIDERS.alert_providers for p in DATA_PROVIDERS.alert_providers
] ]
DATA_STORE.status_data["solar_condition_providers"] = [ DATA_STORE.status.get()["solar_condition_providers"] = [
{ {
"name": p.name, "name": p.name,
"enabled": p.enabled, "enabled": p.enabled,
@@ -91,7 +92,7 @@ class StatusReporter:
} }
for p in DATA_PROVIDERS.solar_condition_providers for p in DATA_PROVIDERS.solar_condition_providers
] ]
DATA_STORE.status_data["static_data_providers"] = [ DATA_STORE.status.get()["static_data_providers"] = [
{ {
"name": p.name, "name": p.name,
"enabled": p.enabled, "enabled": p.enabled,
@@ -102,7 +103,7 @@ class StatusReporter:
} }
for p in DATA_PROVIDERS.static_data_providers for p in DATA_PROVIDERS.static_data_providers
] ]
DATA_STORE.status_data["sig_ref_data_providers"] = [ DATA_STORE.status.get()["sig_ref_data_providers"] = [
{ {
"sig_name": p.sig_name, "sig_name": p.sig_name,
"enabled": p.enabled, "enabled": p.enabled,
@@ -114,7 +115,7 @@ class StatusReporter:
} }
for p in DATA_PROVIDERS.sig_ref_data_providers for p in DATA_PROVIDERS.sig_ref_data_providers
] ]
DATA_STORE.status_data["callsign_data_providers"] = [ DATA_STORE.status.get()["callsign_data_providers"] = [
{ {
"name": p.name, "name": p.name,
"enabled": p.enabled, "enabled": p.enabled,
@@ -126,13 +127,13 @@ class StatusReporter:
} }
for p in DATA_PROVIDERS.callsign_data_providers for p in DATA_PROVIDERS.callsign_data_providers
] ]
DATA_STORE.status_data["cleanup"] = { DATA_STORE.status.get()["cleanup"] = {
"status": CLEANUP_TIMER.status, "status": CLEANUP_TIMER.status,
"last_ran": CLEANUP_TIMER.last_cleanup_time.replace(tzinfo=pytz.UTC).timestamp() "last_ran": CLEANUP_TIMER.last_cleanup_time.replace(tzinfo=pytz.UTC).timestamp()
if CLEANUP_TIMER.last_cleanup_time if CLEANUP_TIMER.last_cleanup_time
else 0, else 0,
} }
DATA_STORE.status_data["webserver"] = { DATA_STORE.status.get()["webserver"] = {
"status": WEB_SERVER.web_server_metrics["status"], "status": WEB_SERVER.web_server_metrics["status"],
"last_api_access": WEB_SERVER.web_server_metrics["last_api_access_time"] "last_api_access": WEB_SERVER.web_server_metrics["last_api_access_time"]
.replace(tzinfo=pytz.UTC) .replace(tzinfo=pytz.UTC)
@@ -147,6 +148,7 @@ class StatusReporter:
else 0, else 0,
"page_access_count": WEB_SERVER.web_server_metrics["page_access_counter"], "page_access_count": WEB_SERVER.web_server_metrics["page_access_counter"],
} }
DATA_STORE.status.store()
# Update Prometheus metrics # Update Prometheus metrics
memory_use_gauge.set(psutil.Process(os.getpid()).memory_info().rss) memory_use_gauge.set(psutil.Process(os.getpid()).memory_info().rss)
+2 -2
View File
@@ -20,7 +20,7 @@ POLL_INTERVAL = 7200
# To avoid looking up all stations in the GIRO system and working out which ones are providing live data, this has been # To avoid looking up all stations in the GIRO system and working out which ones are providing live data, this has been
# manually determined and a CSV provided of all the stations that we can query for live data. # manually determined and a CSV provided of all the stations that we can query for live data.
STATIONS_INDEX = "datafiles/didbase-stations.csv" STATIONS_INDEX = "datafiles/didbase-stations.csv"
LGDC_URL = "https://lgdc.uml.edu/common/DIDBGetValues" LGDC_URL = "https://lgdc.uml.edu/fastchar/getbest"
HISTORY_HOURS = 24 HISTORY_HOURS = 24
@@ -140,7 +140,7 @@ class GIROIonosonde(SolarConditionsProvider):
from_str = from_time.strftime("%Y.%m.%d+%H:%M:%S") from_str = from_time.strftime("%Y.%m.%d+%H:%M:%S")
to_str = to_time.strftime("%Y.%m.%d+%H:%M:%S") to_str = to_time.strftime("%Y.%m.%d+%H:%M:%S")
url = f"{LGDC_URL}?ursiCode={ursi}&charName=foF2,MUFD,fmin&DMUF=3000&fromDate={from_str}&toDate={to_str}" url = f"{LGDC_URL}?ursiCode={ursi}&charName=foF2,MUF%28D%29,fmin&DMUF=3000&fromDate={from_str}&toDate={to_str}"
try: try:
http_response = requests.get(url, headers=HTTP_HEADERS, timeout=(5, 15)) http_response = requests.get(url, headers=HTTP_HEADERS, timeout=(5, 15))
if not http_response.ok: if not http_response.ok:
@@ -16,7 +16,7 @@ class SolarConditionsProvider:
self.enabled = provider_config.get("enabled", True) self.enabled = provider_config.get("enabled", True)
self.last_update_time = datetime.min.replace(tzinfo=pytz.UTC) self.last_update_time = datetime.min.replace(tzinfo=pytz.UTC)
self.status = "Not Started" if self.enabled else "Disabled" self.status = "Not Started" if self.enabled else "Disabled"
self._solar_conditions = DATA_STORE.solar_conditions self._solar_conditions = DATA_STORE.solar_conditions.get()
def start(self): def start(self):
"""Start the provider. This should return immediately after spawning threads to access the remote resources""" """Start the provider. This should return immediately after spawning threads to access the remote resources"""
@@ -36,3 +36,4 @@ class SolarConditionsProvider:
if hasattr(self._solar_conditions, key): if hasattr(self._solar_conditions, key):
setattr(self._solar_conditions, key, value) setattr(self._solar_conditions, key, value)
self._solar_conditions.infer_descriptions() self._solar_conditions.infer_descriptions()
DATA_STORE.solar_conditions.store()
+2 -2
View File
@@ -118,8 +118,8 @@ class GMA(HTTPSpotProvider):
spot.sig_refs[0].sig = "IOTA" spot.sig_refs[0].sig = "IOTA"
spot.sig = "IOTA" spot.sig = "IOTA"
case "GMA Island": case "GMA Island":
spot.sig_refs[0].sig = "GMA Island" spot.sig_refs[0].sig = "GMA Islands"
spot.sig = "GMA Island" spot.sig = "GMA Islands"
case "Lighthouse (ILLW)": case "Lighthouse (ILLW)":
spot.sig_refs[0].sig = "ILLW" spot.sig_refs[0].sig = "ILLW"
spot.sig = "ILLW" spot.sig = "ILLW"
+3 -3
View File
@@ -1,8 +1,8 @@
[project] [project]
name = "Spothole" name = "spothole"
version = "2.0" version = "2.0.5"
authors = [ authors = [
{ name="Ian Renton", email="ian@ianrenton.com" }, { name = "Ian Renton", email = "ian@ianrenton.com" },
] ]
description = "Spothole is a utility to aggregate \"spots\" from amateur radio DX clusters and xOTA spotting sites, and provide an open JSON API as well as a website to browse the data." description = "Spothole is a utility to aggregate \"spots\" from amateur radio DX clusters and xOTA spotting sites, and provide an open JSON API as well as a website to browse the data."
readme = "README.md" readme = "README.md"
+3 -4
View File
@@ -111,7 +111,7 @@ class WebServer:
( (
r"/api/v2/solar", r"/api/v2/solar",
APISolarConditionsHandler, APISolarConditionsHandler,
{"solar_conditions": self._data_store.solar_conditions, **handler_opts}, {"solar_conditions": self._data_store.solar_conditions.get(), **handler_opts},
), ),
( (
r"/api/v2/dxstats", r"/api/v2/dxstats",
@@ -121,12 +121,12 @@ class WebServer:
( (
r"/api/v2/options", r"/api/v2/options",
APIOptionsHandler, APIOptionsHandler,
{"status_data": self._data_store.status_data, **handler_opts}, {"status_data": self._data_store.status.get(), **handler_opts},
), ),
( (
r"/api/v2/status", r"/api/v2/status",
APIStatusHandler, APIStatusHandler,
{"status_data": self._data_store.status_data, **handler_opts}, {"status_data": self._data_store.status.get(), **handler_opts},
), ),
(r"/api/v2/lookup/call", APILookupCallHandler, {**handler_opts}), (r"/api/v2/lookup/call", APILookupCallHandler, {**handler_opts}),
(r"/api/v2/lookup/sigref", APILookupSIGRefHandler, {**handler_opts}), (r"/api/v2/lookup/sigref", APILookupSIGRefHandler, {**handler_opts}),
@@ -160,7 +160,6 @@ class WebServer:
V1APISpotHandler, V1APISpotHandler,
{ {
"spots": self._data_store.spots, "spots": self._data_store.spots,
"spot_providers": self._data_providers,
**handler_opts, **handler_opts,
}, },
), ),
+6 -2
View File
@@ -394,9 +394,11 @@ function setBasemap(basemapname) {
} }
// OpenStreetMap.Mapnik.Dark is a synthetic variant that uses Mapnik tiles with a CSS filter applied // 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; const providerName = basemapname === "OpenStreetMap.Mapnik.Dark" ? "OpenStreetMap.Mapnik" : basemapname;
const isCartoDB = basemapname === "CartoDB.Voyager" || basemapname === "CartoDB.DarkMatter";
backgroundTileLayer = L.tileLayer.provider(providerName, { backgroundTileLayer = L.tileLayer.provider(providerName, {
opacity: parseFloat($("#basemapOpacity").val()), opacity: parseFloat($("#basemapOpacity").val()),
edgeBufferTiles: 1 edgeBufferTiles: 1,
key: isCartoDB ? CARTODB_API_KEY : null
}); });
backgroundTileLayer.addTo(map); backgroundTileLayer.addTo(map);
backgroundTileLayer.bringToBack(); backgroundTileLayer.bringToBack();
@@ -529,9 +531,11 @@ function setUpMap() {
// Add basemap // Add basemap
loadedBasemap = $("#basemap").val(); loadedBasemap = $("#basemap").val();
const initialProviderName = loadedBasemap === "OpenStreetMap.Mapnik.Dark" ? "OpenStreetMap.Mapnik" : loadedBasemap; const initialProviderName = loadedBasemap === "OpenStreetMap.Mapnik.Dark" ? "OpenStreetMap.Mapnik" : loadedBasemap;
const isCartoDB = loadedBasemap === "CartoDB.Voyager" || loadedBasemap === "CartoDB.DarkMatter";
backgroundTileLayer = L.tileLayer.provider(initialProviderName, { backgroundTileLayer = L.tileLayer.provider(initialProviderName, {
opacity: parseFloat($("#basemapOpacity").val()), opacity: parseFloat($("#basemapOpacity").val()),
edgeBufferTiles: 1 edgeBufferTiles: 1,
key: isCartoDB ? CARTODB_API_KEY : null
}); });
backgroundTileLayer.addTo(map); backgroundTileLayer.addTo(map);
backgroundTileLayer.bringToBack(); backgroundTileLayer.bringToBack();
+1 -1
View File
@@ -40,7 +40,7 @@ function setHamHFBandToggles() {
// Generate SIGs filter card. This one is also a special case. // Generate SIGs filter card. This one is also a special case.
function generateSIGsMultiToggleFilterCard(sig_options) { function generateSIGsMultiToggleFilterCard(sig_options) {
const $grid = $('<div class="row row-cols-2 row-cols-xxl-3 g-1 mb-1">'); const $grid = $('<div class="row row-cols-2 g-1 mb-1">');
sig_options.forEach(o => { sig_options.forEach(o => {
const domSafeName = o["name"].replace(/^[^A-Za-z0-9]+|[^\w]+/gi, ""); const domSafeName = o["name"].replace(/^[^A-Za-z0-9]+|[^\w]+/gi, "");
$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>`); $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>`);
+2 -2
View File
@@ -345,7 +345,7 @@ const SIG_ICONS = {
"SOTA": "fa-mountain-sun", "SOTA": "fa-mountain-sun",
"WWFF": "fa-seedling", "WWFF": "fa-seedling",
"GMA": "fa-person-hiking", "GMA": "fa-person-hiking",
"GMA Island": "fa-person-hiking", "GMA Islands": "fa-person-hiking",
"WWBOTA": "fa-radiation", "WWBOTA": "fa-radiation",
"HEMA": "fa-mound", "HEMA": "fa-mound",
"IOTA": "fa-book-atlas", "IOTA": "fa-book-atlas",
@@ -373,7 +373,7 @@ const SIG_NAMES = {
"SOTA": "Summits on the Air", "SOTA": "Summits on the Air",
"WWFF": "Worldwide Flora & Fauna", "WWFF": "Worldwide Flora & Fauna",
"GMA": "Global Mountain Activity", "GMA": "Global Mountain Activity",
"GMA Island": "Global Mountain Activity - Islands", "GMA Islands": "Global Mountain Activity - Islands",
"WWBOTA": "Bunkers on the Air", "WWBOTA": "Bunkers on the Air",
"HEMA": "Humps Excluding Marilyns Award", "HEMA": "Humps Excluding Marilyns Award",
"IOTA": "Islands on the Air", "IOTA": "Islands on the Air",
+3 -2
View File
@@ -801,12 +801,13 @@
} }
}, },
CartoDB: { CartoDB: {
url: 'https://{s}.basemaps.cartocdn.com/{variant}/{z}/{x}/{y}{r}.png', url: 'https://{s}.basemaps.cartocdn.com/{variant}/{z}/{x}/{y}{r}.png?key={key}',
options: { options: {
attribution: '{attribution.OpenStreetMap} &copy; <a href="https://carto.com/attributions">CARTO</a>', attribution: '{attribution.OpenStreetMap} &copy; <a href="https://carto.com/attributions">CARTO</a>',
subdomains: 'abcd', subdomains: 'abcd',
maxZoom: 20, maxZoom: 20,
variant: 'light_all' variant: 'light_all',
key: '<insert your API key here>'
}, },
variants: { variants: {
Positron: 'light_all', Positron: 'light_all',
+1 -1
View File
@@ -76,7 +76,7 @@
</div> </div>
<script src="/static/js/add-spot.js?v=1787382663"></script> <script src="/static/js/add-spot.js?v=1787991444"></script>
<script>$(document).ready(function () { <script>$(document).ready(function () {
$("#nav-link-add-spot").addClass("active"); $("#nav-link-add-spot").addClass("active");
}); <!-- highlight active page in nav --></script> }); <!-- highlight active page in nav --></script>
+1 -1
View File
@@ -84,7 +84,7 @@
</div> </div>
<script src="/static/js/alerts.js?v=1787382663"></script> <script src="/static/js/alerts.js?v=1787991444"></script>
<script>$(document).ready(function () { <script>$(document).ready(function () {
$("#nav-link-alerts").addClass("active"); $("#nav-link-alerts").addClass("active");
}); <!-- highlight active page in nav --></script> }); <!-- highlight active page in nav --></script>
+2 -2
View File
@@ -76,8 +76,8 @@
</div> </div>
<script src="/static/js/spotsbandsandmap.js?v=1787382663"></script> <script src="/static/js/spotsbandsandmap.js?v=1787991444"></script>
<script src="/static/js/bands.js?v=1787382663"></script> <script src="/static/js/bands.js?v=1787991444"></script>
<script>$(document).ready(function () { <script>$(document).ready(function () {
$("#nav-link-bands").addClass("active"); $("#nav-link-bands").addClass("active");
}); <!-- highlight active page in nav --></script> }); <!-- highlight active page in nav --></script>
+5 -5
View File
@@ -1,6 +1,6 @@
{% extends "skeleton.html" %} {% extends "skeleton.html" %}
{% block head_extra %} {% block head_extra %}
<link rel="stylesheet" href="/static/css/style.css?v=1787382663" type="text/css"> <link rel="stylesheet" href="/static/css/style.css?v=1787991444" type="text/css">
<link href="/static/vendor/css/bootstrap-5.3.8.min.css" rel="stylesheet"> <link href="/static/vendor/css/bootstrap-5.3.8.min.css" rel="stylesheet">
<link href="/static/vendor/css/fontawesome-6.7.2.min.css" rel="stylesheet"> <link href="/static/vendor/css/fontawesome-6.7.2.min.css" rel="stylesheet">
<link href="/static/vendor/css/solid-6.7.2.min.css" rel="stylesheet"> <link href="/static/vendor/css/solid-6.7.2.min.css" rel="stylesheet">
@@ -15,10 +15,10 @@
window.fetchEventSource = fetchEventSource; window.fetchEventSource = fetchEventSource;
</script> </script>
<script src="/static/js/utils.js?v=1787382663"></script> <script src="/static/js/utils.js?v=1787991444"></script>
<script src="/static/js/ui-ham.js?v=1787382663"></script> <script src="/static/js/ui-ham.js?v=1787991444"></script>
<script src="/static/js/geo.js?v=1787382663"></script> <script src="/static/js/geo.js?v=1787991444"></script>
<script src="/static/js/common.js?v=1787382663"></script> <script src="/static/js/common.js?v=1787991444"></script>
{% end %} {% end %}
{% block body %} {% block body %}
<div class="container"> <div class="container">
+1 -1
View File
@@ -6,7 +6,7 @@
oninput="filtersUpdated();" style="width: 5em; display: inline-block;"> oninput="filtersUpdated();" style="width: 5em; display: inline-block;">
{% for a in web_ui_options["max_spot_age"] %} {% for a in web_ui_options["max_spot_age"] %}
<option value="{{a*60}}" {% if web_ui_options[ <option value="{{a*60}}" {% if web_ui_options[
"max_spot_age_default"] == a*60 %}selected{% end %}>{{a}}</option> "max_spot_age_default"] == a %}selected{% end %}>{{a}}</option>
{% end %} {% end %}
</select> </select>
minutes minutes
+1 -1
View File
@@ -284,7 +284,7 @@
</div> </div>
<script src="/static/vendor/js/chart-4.4.9.umd.min.js"></script> <script src="/static/vendor/js/chart-4.4.9.umd.min.js"></script>
<script src="/static/js/conditions.js?v=1787382663"></script> <script src="/static/js/conditions.js?v=1787991444"></script>
<script>$(document).ready(function () { <script>$(document).ready(function () {
$("#nav-link-conditions").addClass("active"); $("#nav-link-conditions").addClass("active");
}); <!-- highlight active page in nav --></script> }); <!-- highlight active page in nav --></script>
+6 -2
View File
@@ -109,8 +109,12 @@
<script src="/static/vendor/js/leaflet-cqzones.js"></script> <script src="/static/vendor/js/leaflet-cqzones.js"></script>
<script src="/static/vendor/js/leaflet-workedallbritainireland.js" type="module"></script> <script src="/static/vendor/js/leaflet-workedallbritainireland.js" type="module"></script>
<script src="/static/js/spotsbandsandmap.js?v=1787382663"></script> <script>
<script src="/static/js/map.js?v=1787382663"></script> const CARTODB_API_KEY = "{{ web_ui_options.get('cartodb_api_key', '') }}";
</script>
<script src="/static/js/spotsbandsandmap.js?v=1787991444"></script>
<script src="/static/js/map.js?v=1787991444"></script>
<script>$(document).ready(function () { <script>$(document).ready(function () {
$("#nav-link-map").addClass("active"); $("#nav-link-map").addClass("active");
}); <!-- highlight active page in nav --></script> }); <!-- highlight active page in nav --></script>
+2 -2
View File
@@ -113,8 +113,8 @@
</div> </div>
<script src="/static/js/spotsbandsandmap.js?v=1787382663"></script> <script src="/static/js/spotsbandsandmap.js?v=1787991444"></script>
<script src="/static/js/spots.js?v=1787382663"></script> <script src="/static/js/spots.js?v=1787991444"></script>
<script>$(document).ready(function () { <script>$(document).ready(function () {
$("#nav-link-spots").addClass("active"); $("#nav-link-spots").addClass("active");
}); <!-- highlight active page in nav --></script> }); <!-- highlight active page in nav --></script>
+1 -1
View File
@@ -86,7 +86,7 @@
</div> </div>
</div> </div>
<script src="/static/js/status.js?v=1787382663"></script> <script src="/static/js/status.js?v=1787991444"></script>
<script> <script>
$(document).ready(function () { $(document).ready(function () {
$("#nav-link-status").addClass("active"); $("#nav-link-status").addClass("active");