mirror of
https://git.ianrenton.com/ian/spothole.git
synced 2026-04-29 18:25:58 +00:00
Compare commits
10 Commits
11dd8fa77f
...
76b0ec24b7
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
76b0ec24b7 | ||
|
|
64afd4ed55 | ||
|
|
d71908455a | ||
|
|
c10b5e4947 | ||
|
|
4a6d9da031 | ||
|
|
9d04f8ea38 | ||
|
|
df9a82cad3 | ||
|
|
da7bb4223e | ||
|
|
8d2fcc69b0 | ||
|
|
9cfc3051a5 |
2
.idea/spothole.iml
generated
2
.idea/spothole.iml
generated
@@ -4,7 +4,7 @@
|
||||
<content url="file://$MODULE_DIR$">
|
||||
<excludeFolder url="file://$MODULE_DIR$/.venv" />
|
||||
</content>
|
||||
<orderEntry type="jdk" jdkName="Python 3.13 (spothole)" jdkType="Python SDK" />
|
||||
<orderEntry type="jdk" jdkName="Python 3.13 virtualenv at ~/code/spothole/.venv" jdkType="Python SDK" />
|
||||
<orderEntry type="sourceFolder" forTests="false" />
|
||||
</component>
|
||||
</module>
|
||||
@@ -121,12 +121,20 @@ 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.
|
||||
alert-providers:
|
||||
@@ -167,6 +175,10 @@ 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
|
||||
|
||||
@@ -131,6 +131,14 @@ class SolarConditions:
|
||||
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 x_ray
|
||||
|
||||
@@ -337,9 +337,13 @@ 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 reject that before we try looking anything up
|
||||
# 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):
|
||||
logging.warning("Received strings in lat/lon (" + str(self.dx_latitude) + ", " + str(self.dx_longitude) + ") for call " + self.dx_call + ", rejecting it")
|
||||
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
|
||||
|
||||
|
||||
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
|
||||
|
@@ -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":
|
||||
|
||||
@@ -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)
|
||||
@@ -21,7 +21,7 @@ from server.handlers.pagetemplate import PageTemplateHandler
|
||||
class WebServer:
|
||||
"""Provides the public-facing web server."""
|
||||
|
||||
def __init__(self, spots, alerts, solar_conditions, status_data, port):
|
||||
def __init__(self, spots, alerts, solar_conditions, status_data, solar_condition_providers, port):
|
||||
"""Constructor"""
|
||||
|
||||
self._spots = spots
|
||||
@@ -30,6 +30,7 @@ class WebServer:
|
||||
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 = {
|
||||
@@ -53,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}),
|
||||
@@ -74,20 +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"/conditions", PageTemplateHandler,
|
||||
{"template_name": "conditions", "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"
|
||||
|
||||
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,
|
||||
}
|
||||
@@ -97,7 +97,8 @@ if __name__ == '__main__':
|
||||
lookup_helper.start()
|
||||
|
||||
# Set up web server
|
||||
web_server = WebServer(spots=spots, alerts=alerts, solar_conditions=solar_conditions, 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"]:
|
||||
|
||||
@@ -67,7 +67,7 @@
|
||||
<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=1775203458"></script>
|
||||
<script src="/js/common.js?v=1775249255"></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=1775203458"></script>
|
||||
<script src="/js/add-spot.js?v=1775203458"></script>
|
||||
<script src="/js/common.js?v=1775249255"></script>
|
||||
<script src="/js/add-spot.js?v=1775249255"></script>
|
||||
<script>$(document).ready(function() { $("#nav-link-add-spot").addClass("active"); }); <!-- highlight active page in nav --></script>
|
||||
|
||||
{% end %}
|
||||
@@ -56,8 +56,8 @@
|
||||
|
||||
</div>
|
||||
|
||||
<script src="/js/common.js?v=1775203458"></script>
|
||||
<script src="/js/alerts.js?v=1775203458"></script>
|
||||
<script src="/js/common.js?v=1775249255"></script>
|
||||
<script src="/js/alerts.js?v=1775249255"></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=1775203458"></script>
|
||||
<script src="/js/spotsbandsandmap.js?v=1775203458"></script>
|
||||
<script src="/js/bands.js?v=1775203458"></script>
|
||||
<script src="/js/common.js?v=1775249255"></script>
|
||||
<script src="/js/spotsbandsandmap.js?v=1775249255"></script>
|
||||
<script src="/js/bands.js?v=1775249255"></script>
|
||||
<script>$(document).ready(function() { $("#nav-link-bands").addClass("active"); }); <!-- highlight active page in nav --></script>
|
||||
|
||||
{% end %}
|
||||
@@ -45,11 +45,12 @@
|
||||
integrity="sha384-FKyoEForCGlyvwx9Hj09JcYn3nv7wiPVlz7YYwJrWVcXK/BmnVDxM+D2scQbITxI"
|
||||
crossorigin="anonymous"></script>
|
||||
<script src="https://cdn.jsdelivr.net/npm/tinycolor2@1.6.0/cjs/tinycolor.min.js"></script>
|
||||
<script src="https://cdn.jsdelivr.net/npm/chart.js@4.4.9/dist/chart.umd.min.js"></script>
|
||||
|
||||
<script src="https://misc.ianrenton.com/jsutils/utils.js?v=1775203458"></script>
|
||||
<script src="https://misc.ianrenton.com/jsutils/storage.js?v=1775203458"></script>
|
||||
<script src="https://misc.ianrenton.com/jsutils/ui-ham.js?v=1775203458"></script>
|
||||
<script src="https://misc.ianrenton.com/jsutils/geo.js?v=1775203458"></script>
|
||||
<script src="https://misc.ianrenton.com/jsutils/utils.js?v=1775249255"></script>
|
||||
<script src="https://misc.ianrenton.com/jsutils/storage.js?v=1775249255"></script>
|
||||
<script src="https://misc.ianrenton.com/jsutils/ui-ham.js?v=1775249255"></script>
|
||||
<script src="https://misc.ianrenton.com/jsutils/geo.js?v=1775249255"></script>
|
||||
|
||||
</head>
|
||||
<body>
|
||||
@@ -71,7 +72,9 @@
|
||||
{% 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>
|
||||
<li class="nav-item ms-4"><a href="/apidocs" class="nav-link" id="nav-link-api"><i class="fa-solid fa-gear"></i> API</a></li>
|
||||
|
||||
@@ -1,16 +1,15 @@
|
||||
{% 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">
|
||||
<div class="card h-100">
|
||||
<div class="card-body">
|
||||
<h5 class="card-title">HF</h5>
|
||||
<div class="col mt-3 px-3">
|
||||
<h5>HF</h5>
|
||||
<table class="table table-sm mt-2">
|
||||
<thead>
|
||||
<tr>
|
||||
@@ -43,12 +42,8 @@
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="col">
|
||||
<div class="card h-100">
|
||||
<div class="card-body">
|
||||
<h5 class="card-title">VHF</h5>
|
||||
<div class="col mt-3 px-3">
|
||||
<h5>VHF</h5>
|
||||
<table class="table table-sm mt-2">
|
||||
<thead>
|
||||
<tr>
|
||||
@@ -85,8 +80,6 @@
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="form-text mt-3">Data from <a href="https://hamqsl.com">HamQSL.com</a>.</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -95,7 +88,7 @@
|
||||
<div class="card-header">
|
||||
Solar Weather
|
||||
</div>
|
||||
<div class="card-body">
|
||||
<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">
|
||||
@@ -137,6 +130,46 @@
|
||||
<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>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">
|
||||
@@ -145,7 +178,8 @@
|
||||
<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();">
|
||||
<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>
|
||||
@@ -185,12 +219,18 @@
|
||||
</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 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="/js/common.js?v=1775203458"></script>
|
||||
<script src="/js/conditions.js?v=1775203458"></script>
|
||||
<script>$(document).ready(function() { $("#nav-link-conditions").addClass("active"); }); <!-- highlight active page in nav --></script>
|
||||
<script src="/js/common.js?v=1775249255"></script>
|
||||
<script src="/js/conditions.js?v=1775249255"></script>
|
||||
<script>$(document).ready(function () {
|
||||
$("#nav-link-conditions").addClass("active");
|
||||
}); <!-- highlight active page in nav --></script>
|
||||
|
||||
{% end %}
|
||||
@@ -70,9 +70,9 @@
|
||||
<script>
|
||||
let spotProvidersEnabledByDefault = {% raw json_encode(web_ui_options["spot-providers-enabled-by-default"]) %};
|
||||
</script>
|
||||
<script src="/js/common.js?v=1775203458"></script>
|
||||
<script src="/js/spotsbandsandmap.js?v=1775203458"></script>
|
||||
<script src="/js/map.js?v=1775203458"></script>
|
||||
<script src="/js/common.js?v=1775249255"></script>
|
||||
<script src="/js/spotsbandsandmap.js?v=1775249255"></script>
|
||||
<script src="/js/map.js?v=1775249255"></script>
|
||||
<script>$(document).ready(function() { $("#nav-link-map").addClass("active"); }); <!-- highlight active page in nav --></script>
|
||||
|
||||
{% end %}
|
||||
@@ -87,9 +87,9 @@
|
||||
<script>
|
||||
let spotProvidersEnabledByDefault = {% raw json_encode(web_ui_options["spot-providers-enabled-by-default"]) %};
|
||||
</script>
|
||||
<script src="/js/common.js?v=1775203458"></script>
|
||||
<script src="/js/spotsbandsandmap.js?v=1775203458"></script>
|
||||
<script src="/js/spots.js?v=1775203458"></script>
|
||||
<script src="/js/common.js?v=1775249255"></script>
|
||||
<script src="/js/spotsbandsandmap.js?v=1775249255"></script>
|
||||
<script src="/js/spots.js?v=1775249255"></script>
|
||||
<script>$(document).ready(function() { $("#nav-link-spots").addClass("active"); }); <!-- highlight active page in nav --></script>
|
||||
|
||||
{% end %}
|
||||
@@ -59,8 +59,8 @@
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<script src="/js/common.js?v=1775203458"></script>
|
||||
<script src="/js/status.js?v=1775203458"></script>
|
||||
<script src="/js/common.js?v=1775249255"></script>
|
||||
<script src="/js/status.js?v=1775249255"></script>
|
||||
<script>
|
||||
$(document).ready(function() { $("#nav-link-status").addClass("active"); }); <!-- highlight active page in nav -->
|
||||
</script>
|
||||
|
||||
@@ -294,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.
|
||||
@@ -1507,6 +1507,63 @@ components:
|
||||
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
|
||||
blackout_desc:
|
||||
type: string
|
||||
description: HF radio blackout risk description, derived from the X-ray flux class.
|
||||
|
||||
@@ -1,6 +1,8 @@
|
||||
// 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() {
|
||||
@@ -82,7 +84,7 @@ function loadSolarConditions() {
|
||||
const kIndex = jsonData.k_index;
|
||||
if (kIndex !== null && kIndex !== undefined) {
|
||||
applySwClass('sw-geomag-vals', 'sw-geomag-desc',
|
||||
kIndex < 5 ? 'bg-success-subtle' : kIndex < 7 ? 'bg-warning-subtle' : 'bg-danger-subtle');
|
||||
kIndex < 5 ? 'bg-success-subtle' : kIndex < 6 ? 'bg-warning-subtle' : 'bg-danger-subtle');
|
||||
}
|
||||
|
||||
const xRay = jsonData.x_ray;
|
||||
@@ -105,9 +107,220 @@ function loadSolarConditions() {
|
||||
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;
|
||||
|
||||
// x-axis labels. Show date only on the first bar of each day, time on all bars
|
||||
const labels = entries.map((e, i) => {
|
||||
const dt = new Date(e.ts * 1000);
|
||||
const timeStr = String(dt.getUTCHours()).padStart(2, '0') + ':00';
|
||||
const dateStr = dt.toLocaleDateString('en-GB', { day: '2-digit', month: 'short', timeZone: 'UTC' });
|
||||
const prev = i > 0 ? new Date(entries[i - 1].ts * 1000) : null;
|
||||
const newDay = !prev || prev.toISOString().slice(0, 10) !== dt.toISOString().slice(0, 10);
|
||||
return newDay ? [timeStr, dateStr] : timeStr;
|
||||
});
|
||||
|
||||
// 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 },
|
||||
};
|
||||
const timeAxis = {
|
||||
title: { display: true, text: 'Time (UTC)', color: textColor },
|
||||
ticks: { color: textColor, maxRotation: 45, minRotation: 0 },
|
||||
grid: { color: gridColor },
|
||||
};
|
||||
|
||||
// Draw a "now" line at the current time position
|
||||
const nowLinePlugin = {
|
||||
id: 'nowLine',
|
||||
afterDraw(chart) {
|
||||
const nowTs = Date.now() / 1000;
|
||||
// Find the fractional bar index for the current time
|
||||
let fracIndex = null;
|
||||
for (let i = 0; i < entries.length - 1; i++) {
|
||||
if (nowTs >= entries[i].ts && nowTs < entries[i + 1].ts) {
|
||||
fracIndex = i + (nowTs - entries[i].ts) / (entries[i + 1].ts - entries[i].ts);
|
||||
break;
|
||||
}
|
||||
}
|
||||
if (fracIndex === null) return; // now is outside the chart range
|
||||
|
||||
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();
|
||||
}
|
||||
};
|
||||
|
||||
kpChart = new Chart(document.getElementById('forecast-kp-chart'), {
|
||||
type: 'bar',
|
||||
data: {
|
||||
labels,
|
||||
datasets: [{
|
||||
data: entries.map(e => e.kp),
|
||||
backgroundColor: colors,
|
||||
hoverBackgroundColor: colors,
|
||||
borderWidth: 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-US', { 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; }
|
||||
|
||||
Reference in New Issue
Block a user