mirror of
https://git.ianrenton.com/ian/spothole.git
synced 2026-08-06 18:41:41 +00:00
Compare commits
4
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
d655354d05 | ||
|
|
a7a45190cb | ||
|
|
6058eb5053 | ||
|
|
3e7d2c2bc2 |
@@ -161,8 +161,8 @@ class SolarConditions:
|
||||
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
|
||||
# Ionosonde measurements from LGDC, list of dicts with keys: ursi, name, fof2, muf
|
||||
ionosonde_data: list = None
|
||||
# Ionosonde measurements from LGDC, dict keyed by URSI code, values are dicts with keys: ursi, name, fof2, muf
|
||||
ionosonde_data: dict = None
|
||||
|
||||
# Derived values (populated by infer_descriptions())
|
||||
# HF radio blackout risk description, derived from xray
|
||||
|
||||
@@ -35,6 +35,16 @@ class GIROIonosonde(SolarConditionsProvider):
|
||||
stations.append({"ursi": row[0].strip(), "name": row[1].strip()})
|
||||
return stations
|
||||
|
||||
def setup(self, solar_conditions, solar_conditions_cache):
|
||||
"""Prepopulate the ionosonde_data map with known URSI and station names, so that the API exposes this structure
|
||||
even before we actually have any data in it."""
|
||||
|
||||
super().setup(solar_conditions, solar_conditions_cache)
|
||||
self.update_data({"ionosonde_data": {
|
||||
s["ursi"]: {"ursi": s["ursi"], "name": s["name"], "fof2": None, "muf": None}
|
||||
for s in self._stations
|
||||
}})
|
||||
|
||||
def start(self):
|
||||
logging.info(f"Set up query of GIRO ionosonde data API every {POLL_INTERVAL} seconds.")
|
||||
self._thread = Thread(target=self._run, daemon=True)
|
||||
@@ -51,34 +61,33 @@ class GIROIonosonde(SolarConditionsProvider):
|
||||
|
||||
def _poll(self):
|
||||
try:
|
||||
logging.debug(f"Polling {self.name} ionosonde data...")
|
||||
logging.debug(f"Polling GIRO ionosonde data...")
|
||||
now = datetime.now(timezone.utc)
|
||||
from_time = now - timedelta(hours=HISTORY_HOURS)
|
||||
results = []
|
||||
ionosonde_data = dict(self._solar_conditions.ionosonde_data or {})
|
||||
updated_count = 0
|
||||
|
||||
for station in self._stations:
|
||||
if self._stop_event.is_set():
|
||||
break
|
||||
ursi = station["ursi"]
|
||||
name = station["name"]
|
||||
entry = {"ursi": ursi, "name": name, "fof2": None, "muf": None}
|
||||
try:
|
||||
fof2, muf = self._fetch_station_data(ursi, from_time, now)
|
||||
entry["fof2"] = fof2
|
||||
entry["muf"] = muf
|
||||
if fof2 and muf:
|
||||
results.append(entry)
|
||||
ionosonde_data[ursi] = {"ursi": ursi, "name": name, "fof2": fof2, "muf": muf}
|
||||
updated_count += 1
|
||||
except Exception:
|
||||
logging.debug(f"Could not fetch ionosonde data for {ursi} ({name})")
|
||||
logging.warning(f"Could not fetch ionosonde data for {ursi} ({name})")
|
||||
|
||||
self.update_data({"ionosonde_data": results})
|
||||
self.update_data({"ionosonde_data": ionosonde_data})
|
||||
self.status = "OK"
|
||||
self.last_update_time = datetime.now(pytz.UTC)
|
||||
logging.debug(f"Received ionosonde data for {len(results)} stations from {self.name}.")
|
||||
logging.debug(f"Updated ionosonde data for {updated_count} stations.")
|
||||
|
||||
except Exception:
|
||||
self.status = "Error"
|
||||
logging.exception(f"Exception in GIRO Ionosonde data provider ({self.name})")
|
||||
logging.exception(f"Exception in GIRO Ionosonde data provider")
|
||||
self._stop_event.wait(timeout=1)
|
||||
|
||||
def _fetch_station_data(self, ursi, from_time, to_time):
|
||||
@@ -86,9 +95,7 @@ class GIROIonosonde(SolarConditionsProvider):
|
||||
|
||||
from_str = from_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&DMUF=3000&fromDate={from_str}&toDate={to_str}"
|
||||
)
|
||||
url = f"{LGDC_URL}?ursiCode={ursi}&charName=foF2,MUFD&DMUF=3000&fromDate={from_str}&toDate={to_str}"
|
||||
response = requests.get(url, headers=HTTP_HEADERS, timeout=(5, 15))
|
||||
if response.status_code != 200:
|
||||
return None, None
|
||||
@@ -104,10 +111,11 @@ class GIROIonosonde(SolarConditionsProvider):
|
||||
line = line.strip()
|
||||
if not line or line.startswith('#'):
|
||||
continue
|
||||
# Data rows: timestamp CS foF2 QD MUFD QD
|
||||
# Data rows have the following format: timestamp CS foF2 QD MUFD QD
|
||||
parts = line.split()
|
||||
if len(parts) >= 5:
|
||||
try:
|
||||
# Python 3.8 TZ parsing fudge
|
||||
ts = datetime.fromisoformat(parts[0].replace('Z', '+00:00')).timestamp()
|
||||
except ValueError:
|
||||
continue
|
||||
|
||||
@@ -16,10 +16,11 @@ class SolarConditionsProvider:
|
||||
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"""
|
||||
def setup(self, solar_conditions, solar_conditions_cache):
|
||||
"""Set up the provider, giving it the solar conditions object and its backing cache"""
|
||||
|
||||
self._solar_conditions = solar_conditions
|
||||
self._solar_conditions_cache = solar_conditions_cache
|
||||
|
||||
def start(self):
|
||||
"""Start the provider. This should return immediately after spawning threads to access the remote resources"""
|
||||
@@ -39,3 +40,4 @@ class SolarConditionsProvider:
|
||||
if hasattr(self._solar_conditions, key):
|
||||
setattr(self._solar_conditions, key, value)
|
||||
self._solar_conditions.infer_descriptions()
|
||||
self._solar_conditions_cache['solar_conditions'] = self._solar_conditions
|
||||
|
||||
+4
-2
@@ -18,7 +18,8 @@ from server.webserver import WebServer
|
||||
# Globals
|
||||
spots = Cache('cache/spots_cache')
|
||||
alerts = Cache('cache/alerts_cache')
|
||||
solar_conditions = SolarConditions()
|
||||
solar_conditions_cache = Cache('cache/solar_conditions_cache')
|
||||
solar_conditions = solar_conditions_cache.get('solar_conditions', SolarConditions())
|
||||
web_server = None
|
||||
status_data = {}
|
||||
spot_providers = []
|
||||
@@ -48,6 +49,7 @@ def shutdown(sig, frame):
|
||||
lookup_helper.stop()
|
||||
spots.close()
|
||||
alerts.close()
|
||||
solar_conditions_cache.close()
|
||||
os._exit(0)
|
||||
|
||||
|
||||
@@ -120,7 +122,7 @@ if __name__ == '__main__':
|
||||
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)
|
||||
p.setup(solar_conditions=solar_conditions, solar_conditions_cache=solar_conditions_cache)
|
||||
if p.enabled:
|
||||
p.start()
|
||||
|
||||
|
||||
@@ -69,7 +69,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=1778868536"></script>
|
||||
<script src="/js/common.js?v=1778927183"></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=1778868536"></script>
|
||||
<script src="/js/add-spot.js?v=1778868536"></script>
|
||||
<script src="/js/common.js?v=1778927183"></script>
|
||||
<script src="/js/add-spot.js?v=1778927183"></script>
|
||||
<script>$(document).ready(function() { $("#nav-link-add-spot").addClass("active"); }); <!-- highlight active page in nav --></script>
|
||||
|
||||
{% end %}
|
||||
@@ -70,8 +70,8 @@
|
||||
|
||||
</div>
|
||||
|
||||
<script src="/js/common.js?v=1778868536"></script>
|
||||
<script src="/js/alerts.js?v=1778868536"></script>
|
||||
<script src="/js/common.js?v=1778927183"></script>
|
||||
<script src="/js/alerts.js?v=1778927183"></script>
|
||||
<script>$(document).ready(function() { $("#nav-link-alerts").addClass("active"); }); <!-- highlight active page in nav --></script>
|
||||
|
||||
{% end %}
|
||||
@@ -76,9 +76,9 @@
|
||||
<script>
|
||||
let spotProvidersEnabledByDefault = {% raw json_encode(web_ui_options["spot-providers-enabled-by-default"]) %};
|
||||
</script>
|
||||
<script src="/js/common.js?v=1778868536"></script>
|
||||
<script src="/js/spotsbandsandmap.js?v=1778868536"></script>
|
||||
<script src="/js/bands.js?v=1778868536"></script>
|
||||
<script src="/js/common.js?v=1778927183"></script>
|
||||
<script src="/js/spotsbandsandmap.js?v=1778927183"></script>
|
||||
<script src="/js/bands.js?v=1778927183"></script>
|
||||
<script>$(document).ready(function() { $("#nav-link-bands").addClass("active"); }); <!-- highlight active page in nav --></script>
|
||||
|
||||
{% end %}
|
||||
+4
-4
@@ -24,7 +24,7 @@
|
||||
|
||||
<title>Spothole</title>
|
||||
|
||||
<link rel="stylesheet" href="/css/style.css?v=1778868536" type="text/css">
|
||||
<link rel="stylesheet" href="/css/style.css?v=1778927183" 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" />
|
||||
@@ -52,9 +52,9 @@
|
||||
integrity="sha384-L1eE4eD41kpBIWe2I0eHy+GnEUC4RIpcvibVW2JCminuPlTl+2Bc528iPdVMg5Dn"
|
||||
crossorigin="anonymous"></script>
|
||||
|
||||
<script src="https://misc.ianrenton.com/jsutils/utils.js?v=1778868536"></script>
|
||||
<script src="https://misc.ianrenton.com/jsutils/ui-ham.js?v=1778868536"></script>
|
||||
<script src="https://misc.ianrenton.com/jsutils/geo.js?v=1778868536"></script>
|
||||
<script src="https://misc.ianrenton.com/jsutils/utils.js?v=1778927183"></script>
|
||||
<script src="https://misc.ianrenton.com/jsutils/ui-ham.js?v=1778927183"></script>
|
||||
<script src="https://misc.ianrenton.com/jsutils/geo.js?v=1778927183"></script>
|
||||
|
||||
</head>
|
||||
<body>
|
||||
|
||||
@@ -176,7 +176,7 @@
|
||||
{% if has_giro_ionosonde %}
|
||||
<div class="card mt-5">
|
||||
<div class="card-header">
|
||||
Critical & Maximum Usable Frequencies
|
||||
Ionosonde Data
|
||||
</div>
|
||||
<div class="card-body">
|
||||
<div class="mb-3">
|
||||
@@ -186,7 +186,7 @@
|
||||
</select>
|
||||
</div>
|
||||
<div id="ionosonde-latest" class="mb-3"></div>
|
||||
<canvas id="ionosonde-chart" class="mt-3 mb-3 d-none d-md-block"></canvas>
|
||||
<canvas id="ionosonde-chart" class="mt-3 mb-3 hideonmobile"></canvas>
|
||||
<div class="form-text mt-2">Data from the <a href="https://lgdc.uml.edu/">Lowell GIRO Data Center</a>.</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -249,8 +249,8 @@
|
||||
</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=1778868536"></script>
|
||||
<script src="/js/conditions.js?v=1778868536"></script>
|
||||
<script src="/js/common.js?v=1778927183"></script>
|
||||
<script src="/js/conditions.js?v=1778927183"></script>
|
||||
<script>$(document).ready(function () {
|
||||
$("#nav-link-conditions").addClass("active");
|
||||
}); <!-- highlight active page in nav --></script>
|
||||
|
||||
+3
-3
@@ -94,9 +94,9 @@
|
||||
<script>
|
||||
let spotProvidersEnabledByDefault = {% raw json_encode(web_ui_options["spot-providers-enabled-by-default"]) %};
|
||||
</script>
|
||||
<script src="/js/common.js?v=1778868536"></script>
|
||||
<script src="/js/spotsbandsandmap.js?v=1778868536"></script>
|
||||
<script src="/js/map.js?v=1778868536"></script>
|
||||
<script src="/js/common.js?v=1778927183"></script>
|
||||
<script src="/js/spotsbandsandmap.js?v=1778927183"></script>
|
||||
<script src="/js/map.js?v=1778927183"></script>
|
||||
<script>$(document).ready(function() { $("#nav-link-map").addClass("active"); }); <!-- highlight active page in nav --></script>
|
||||
|
||||
{% end %}
|
||||
@@ -104,9 +104,9 @@
|
||||
<script>
|
||||
let spotProvidersEnabledByDefault = {% raw json_encode(web_ui_options["spot-providers-enabled-by-default"]) %};
|
||||
</script>
|
||||
<script src="/js/common.js?v=1778868536"></script>
|
||||
<script src="/js/spotsbandsandmap.js?v=1778868536"></script>
|
||||
<script src="/js/spots.js?v=1778868536"></script>
|
||||
<script src="/js/common.js?v=1778927183"></script>
|
||||
<script src="/js/spotsbandsandmap.js?v=1778927183"></script>
|
||||
<script src="/js/spots.js?v=1778927183"></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=1778868536"></script>
|
||||
<script src="/js/status.js?v=1778868536"></script>
|
||||
<script src="/js/common.js?v=1778927183"></script>
|
||||
<script src="/js/status.js?v=1778927183"></script>
|
||||
<script>
|
||||
$(document).ready(function() { $("#nav-link-status").addClass("active"); }); <!-- highlight active page in nav -->
|
||||
</script>
|
||||
|
||||
@@ -15,7 +15,7 @@ info:
|
||||
|
||||
### 1.4
|
||||
|
||||
* `/solar` response now includes `ionosonde_data`, a list of ionosonde station measurements (foF2 and MUF) sourced from the GIRO Data Center.
|
||||
* `/solar` response now includes `ionosonde_data`, which contains ionosonde station measurements (foF2 and MUF) sourced from the GIRO Data Center.
|
||||
|
||||
### 1.3
|
||||
|
||||
@@ -1688,13 +1688,12 @@ components:
|
||||
description: Electron flux impact description, derived from electron flux level.
|
||||
example: "No impact"
|
||||
ionosonde_data:
|
||||
type: array
|
||||
type: object
|
||||
nullable: true
|
||||
description: >
|
||||
Ionosonde measurements from the GIRO Data Center, covering active stations listed in the
|
||||
system. Only stations for which data was successfully retrieved are included. Null if the
|
||||
GIROIonosonde provider has not yet completed its first poll.
|
||||
items:
|
||||
Ionosonde measurements from the GIRO Data Center, keyed by URSI station code. All known stations are
|
||||
included, but not all of them may contain data.
|
||||
additionalProperties:
|
||||
$ref: '#/components/schemas/IonosondeStation'
|
||||
|
||||
IonosondeStation:
|
||||
@@ -1712,7 +1711,7 @@ components:
|
||||
fof2:
|
||||
type: object
|
||||
nullable: true
|
||||
description: F2 layer critical frequency (foF2) measurements in MHz, keyed by UNIX timestamp (UTC seconds since epoch) of each measurement.
|
||||
description: F2 layer critical frequency (foF2) measurements in MHz, keyed by UNIX timestamp (UTC seconds since epoch) of each measurement. Can be null if there is no data.
|
||||
additionalProperties:
|
||||
type: number
|
||||
example:
|
||||
@@ -1721,7 +1720,7 @@ components:
|
||||
muf:
|
||||
type: object
|
||||
nullable: true
|
||||
description: Maximum Usable Frequency (MUF) for a 3000 km path in MHz, keyed by UNIX timestamp (UTC seconds since epoch) of each measurement.
|
||||
description: Maximum Usable Frequency (MUF) for a 3000 km path in MHz, keyed by UNIX timestamp (UTC seconds since epoch) of each measurement. Can be null if there is no data.
|
||||
additionalProperties:
|
||||
type: number
|
||||
example:
|
||||
|
||||
+42
-13
@@ -115,7 +115,7 @@ function loadSolarConditions() {
|
||||
|
||||
// Ionosonde
|
||||
|
||||
if (jsonData.ionosonde_data && jsonData.ionosonde_data.length > 0) {
|
||||
if (jsonData.ionosonde_data && Object.keys(jsonData.ionosonde_data).length > 0) {
|
||||
ionosondeData = jsonData.ionosonde_data;
|
||||
populateIonosondeDropdown(ionosondeData);
|
||||
renderIonosondeData();
|
||||
@@ -366,9 +366,12 @@ function populateIonosondeDropdown(data) {
|
||||
const savedUrsi = localStorage.getItem('#ionosonde-station:value');
|
||||
const savedValue = savedUrsi ? JSON.parse(savedUrsi) : null;
|
||||
select.empty();
|
||||
data.forEach(function (station) {
|
||||
// Sort by station name rather than URSI because station name is what's displayed, and any out-of-order names might
|
||||
// confuse the user
|
||||
Object.values(data).sort((a, b) => a.name.localeCompare(b.name)).forEach(function (station) {
|
||||
select.append($('<option>', {value: station.ursi, text: station.name}));
|
||||
});
|
||||
// Select one by default if the user's localStorage has an existing selection for this
|
||||
if (savedValue && select.find('option[value="' + savedValue + '"]').length) {
|
||||
select.val(savedValue);
|
||||
}
|
||||
@@ -376,20 +379,25 @@ function populateIonosondeDropdown(data) {
|
||||
|
||||
// Render the foF2/MUF data and line chart for the currently selected station
|
||||
function renderIonosondeData() {
|
||||
// First make sure that we have some data, that a station entry is selected in the drop-down box, and that the
|
||||
// data contains an entry for that station. If not, this represents an odd state (over and above just "no data for
|
||||
// this station", so bail out at this point. The user will have to reselect something from the list, or wait until
|
||||
// the API is behaving itself again.
|
||||
if (!ionosondeData) return;
|
||||
const ursi = $('#ionosonde-station').val();
|
||||
if (!ursi) return;
|
||||
const station = ionosondeData.find(function (s) {
|
||||
return s.ursi === ursi;
|
||||
});
|
||||
const station = ionosondeData[ursi];
|
||||
if (!station) return;
|
||||
|
||||
// Set up some styles, matching the k-index chart. We use Bootstrap's "primary" and "danger" colours not for any
|
||||
// real reason but just to get a suitable blue and red that match the other colours Spothole uses
|
||||
const style = getComputedStyle(document.documentElement);
|
||||
const fof2Color = style.getPropertyValue('--bs-primary').trim();
|
||||
const mufColor = 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)';
|
||||
|
||||
// Utility function to convert the dict of timestamp-to-value into just a value array in timestamp key order
|
||||
function toSeries(dict) {
|
||||
if (!dict) return [];
|
||||
return Object.entries(dict)
|
||||
@@ -400,23 +408,33 @@ function renderIonosondeData() {
|
||||
const fof2Entries = toSeries(station.fof2);
|
||||
const mufEntries = toSeries(station.muf);
|
||||
const allTs = [...fof2Entries, ...mufEntries].map(e => e.ts);
|
||||
if (allTs.length === 0) return;
|
||||
if (allTs.length === 0) {
|
||||
$('#ionosonde-latest').html('<div class="alert alert-warning mt-2 mb-0 py-2">No data available for this station.</div>');
|
||||
$('#ionosonde-chart').hide();
|
||||
if (ionosondeChart) { ionosondeChart.destroy(); ionosondeChart = null; }
|
||||
return;
|
||||
}
|
||||
|
||||
// Populate latest values summary (visible on all screen sizes)
|
||||
const latestFof2 = fof2Entries.length ? fof2Entries[fof2Entries.length - 1].val : null;
|
||||
const latestMuf = mufEntries.length ? mufEntries[mufEntries.length - 1].val : null;
|
||||
const latestTs = allTs.length ? Math.max(...allTs) : null;
|
||||
var latestTimeStr = '';
|
||||
if (latestTs != null) {
|
||||
const latestDate = moment.utc(latestTs * 1000);
|
||||
const minTs = allTs.length ? Math.min(...allTs) : null;
|
||||
const maxTs = allTs.length ? Math.max(...allTs) : null;
|
||||
let latestTimeStr = '';
|
||||
if (maxTs != null) {
|
||||
const latestDate = moment.utc(maxTs * 1000);
|
||||
latestTimeStr = latestDate.format('DD MMM YYYY HH:mm [UTC]') + ' (' + latestDate.fromNow() + ')';
|
||||
}
|
||||
const staleWarning = (maxTs !== null && (Date.now() / 1000 - maxTs) > 12 * 3600)
|
||||
? '<div class="alert alert-warning mt-2 mb-0 py-2">Data is more than 12 hours old!</div>'
|
||||
: '';
|
||||
$('#ionosonde-latest').html(
|
||||
'<div class="row border-bottom align-items-center me-0">' +
|
||||
'<div class="col-12 col-md-6 py-2 text-muted">Latest values as of ' + latestTimeStr + '</div>' +
|
||||
'<div class="col-12 col-md-2 py-2">foF2: <strong>' + (latestFof2 !== null ? latestFof2.toFixed(2) + ' MHz' : 'N/A') + '</strong></div>' +
|
||||
'<div class="col-12 col-md-4 py-2">MUF (3000 km): <strong>' + (latestMuf !== null ? latestMuf.toFixed(2) + ' MHz' : 'N/A') + '</strong></div>' +
|
||||
'</div>' +
|
||||
staleWarning +
|
||||
'</div>'
|
||||
);
|
||||
|
||||
@@ -424,9 +442,6 @@ function renderIonosondeData() {
|
||||
ionosondeChart.destroy();
|
||||
}
|
||||
|
||||
const minTs = Math.min(...allTs);
|
||||
const maxTs = Math.max(...allTs);
|
||||
|
||||
// Compute tick positions at 3-hour UTC boundaries so midnight always lands on a tick, which triggers the date being
|
||||
// printed, and in general looks nicer than arbitrary ticks based on min & max timestamp
|
||||
const tickStep = 3 * 3600;
|
||||
@@ -437,6 +452,7 @@ function renderIonosondeData() {
|
||||
}
|
||||
tickValues.push(maxTs);
|
||||
|
||||
// Build time axis
|
||||
const timeAxis = {
|
||||
type: 'linear',
|
||||
min: minTs,
|
||||
@@ -450,6 +466,8 @@ function renderIonosondeData() {
|
||||
maxRotation: 45,
|
||||
minRotation: 0,
|
||||
callback(value) {
|
||||
// Use the same type of display as in the k-index chart, where the labels on the axis are just HH:mm
|
||||
// unless that's 00:00, in which case add the short date as well.
|
||||
const dt = new Date(value * 1000);
|
||||
const h = dt.getUTCHours();
|
||||
const m = dt.getUTCMinutes();
|
||||
@@ -463,6 +481,8 @@ function renderIonosondeData() {
|
||||
grid: {color: gridColor},
|
||||
};
|
||||
|
||||
// Build frequency axis. This is pretty normal except there's no grid, because we draw extra horizontal lines for
|
||||
// the amateur radio bands which function as grid lines for the frequency axis.
|
||||
const freqAxis = {
|
||||
min: 0,
|
||||
title: {display: true, text: 'Frequency (MHz)', color: textColor},
|
||||
@@ -470,6 +490,7 @@ function renderIonosondeData() {
|
||||
grid: {display: false},
|
||||
};
|
||||
|
||||
// List of ham bands for drawing horizontal lines
|
||||
const AMATEUR_BANDS = [
|
||||
{label: '160m', freq: 1.8},
|
||||
{label: '80m', freq: 3.5},
|
||||
@@ -483,6 +504,7 @@ function renderIonosondeData() {
|
||||
{label: '10m', freq: 28.0},
|
||||
];
|
||||
|
||||
// Build the horizontal lines for each ham band, including a label on the right-hand side.
|
||||
const bandLinesPlugin = {
|
||||
id: 'bandLines',
|
||||
beforeDatasetsDraw(chart) {
|
||||
@@ -492,6 +514,8 @@ function renderIonosondeData() {
|
||||
ctx.strokeStyle = gridColor;
|
||||
ctx.lineWidth = 1;
|
||||
ctx.setLineDash([]);
|
||||
// Add an extra vertical line for 30MHz, which should correspond to the top of the chart and avoid having
|
||||
// no top "border" gridline
|
||||
const y30 = scales.y.getPixelForValue(30);
|
||||
if (y30 >= chartArea.top && y30 <= chartArea.bottom) {
|
||||
ctx.beginPath();
|
||||
@@ -501,6 +525,7 @@ function renderIonosondeData() {
|
||||
}
|
||||
ctx.font = '10px sans-serif';
|
||||
ctx.fillStyle = textColor;
|
||||
// Add the ham band "grid lines"
|
||||
AMATEUR_BANDS.forEach(({label, freq}) => {
|
||||
const y = scales.y.getPixelForValue(freq);
|
||||
if (y < chartArea.top || y > chartArea.bottom) return;
|
||||
@@ -516,6 +541,7 @@ function renderIonosondeData() {
|
||||
}
|
||||
};
|
||||
|
||||
// Create the chart itself
|
||||
ionosondeChart = new Chart(document.getElementById('ionosonde-chart'), {
|
||||
type: 'line',
|
||||
data: {
|
||||
@@ -549,6 +575,9 @@ function renderIonosondeData() {
|
||||
},
|
||||
plugins: [bandLinesPlugin],
|
||||
});
|
||||
|
||||
// Chart canvas is normally hidden until we get here with some definitely good data. Now we have that so show it
|
||||
$('#ionosonde-chart').show();
|
||||
}
|
||||
|
||||
// Called when the ionosonde station select changes
|
||||
|
||||
Reference in New Issue
Block a user