mirror of
https://git.ianrenton.com/ian/spothole.git
synced 2026-05-30 17:35:11 +00:00
Support fetching ionosonde data for FoF2 and MUF display on the Conditions page
This commit is contained in:
@@ -1,8 +1,12 @@
|
||||
// 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
|
||||
// Kp forecast chart
|
||||
let kpChart = null;
|
||||
// Cache for ionosonde data from the API
|
||||
let ionosondeData = null;
|
||||
// Ionosonde foF2/MUF chart
|
||||
let ionosondeChart = null;
|
||||
|
||||
// Load solar conditions
|
||||
function loadSolarConditions() {
|
||||
@@ -109,6 +113,14 @@ function loadSolarConditions() {
|
||||
electronFlux <= 100 ? 'bg-success-subtle' : electronFlux <= 1000 ? 'bg-warning-subtle' : 'bg-danger-subtle');
|
||||
}
|
||||
|
||||
// Ionosonde
|
||||
|
||||
if (jsonData.ionosonde_data && jsonData.ionosonde_data.length > 0) {
|
||||
ionosondeData = jsonData.ionosonde_data;
|
||||
populateIonosondeDropdown(ionosondeData);
|
||||
renderIonosondeData();
|
||||
}
|
||||
|
||||
// Forecast
|
||||
|
||||
renderKIndexForecast(jsonData.k_index_forecast);
|
||||
@@ -348,6 +360,203 @@ function renderBlackoutForecast(r1r2Data, r3Data) {
|
||||
.append(makeRow('R3 or greater', e => e.r3));
|
||||
}
|
||||
|
||||
// Populate the ionosonde station dropdown and restore any saved selection
|
||||
function populateIonosondeDropdown(data) {
|
||||
const select = $('#ionosonde-station');
|
||||
const savedUrsi = localStorage.getItem('#ionosonde-station:value');
|
||||
const savedValue = savedUrsi ? JSON.parse(savedUrsi) : null;
|
||||
select.empty();
|
||||
data.forEach(function (station) {
|
||||
select.append($('<option>', {value: station.ursi, text: station.name}));
|
||||
});
|
||||
if (savedValue && select.find('option[value="' + savedValue + '"]').length) {
|
||||
select.val(savedValue);
|
||||
}
|
||||
}
|
||||
|
||||
// Render the foF2/MUF data and line chart for the currently selected station
|
||||
function renderIonosondeData() {
|
||||
if (!ionosondeData) return;
|
||||
const ursi = $('#ionosonde-station').val();
|
||||
if (!ursi) return;
|
||||
const station = ionosondeData.find(function (s) {
|
||||
return s.ursi === ursi;
|
||||
});
|
||||
if (!station) return;
|
||||
|
||||
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)';
|
||||
|
||||
function toSeries(dict) {
|
||||
if (!dict) return [];
|
||||
return Object.entries(dict)
|
||||
.map(([tsStr, val]) => ({ts: parseFloat(tsStr), val}))
|
||||
.sort((a, b) => a.ts - b.ts);
|
||||
}
|
||||
|
||||
const fof2Entries = toSeries(station.fof2);
|
||||
const mufEntries = toSeries(station.muf);
|
||||
const allTs = [...fof2Entries, ...mufEntries].map(e => e.ts);
|
||||
if (allTs.length === 0) 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);
|
||||
latestTimeStr = latestDate.format('DD MMM YYYY HH:mm [UTC]') + ' (' + latestDate.fromNow() + ')';
|
||||
}
|
||||
$('#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-6 py-2">' +
|
||||
'<span class="me-5">foF2: <strong>' + (latestFof2 !== null ? latestFof2.toFixed(2) + ' MHz' : 'N/A') + '</strong></span>' +
|
||||
'<span>MUF (3000 km): <strong>' + (latestMuf !== null ? latestMuf.toFixed(2) + ' MHz' : 'N/A') + '</strong></span>' +
|
||||
'</div>' +
|
||||
'</div>'
|
||||
);
|
||||
|
||||
if (ionosondeChart) {
|
||||
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;
|
||||
const tickValues = [];
|
||||
for (let t = Math.ceil(minTs / tickStep) * tickStep; t <= maxTs; t += tickStep) {
|
||||
tickValues.push(t);
|
||||
}
|
||||
tickValues.push(maxTs);
|
||||
|
||||
const timeAxis = {
|
||||
type: 'linear',
|
||||
min: minTs,
|
||||
max: maxTs,
|
||||
title: {display: true, text: 'Time (UTC)', color: textColor},
|
||||
afterBuildTicks(axis) {
|
||||
axis.ticks = tickValues.map(v => ({value: v}));
|
||||
},
|
||||
ticks: {
|
||||
color: textColor,
|
||||
maxRotation: 45,
|
||||
minRotation: 0,
|
||||
callback(value) {
|
||||
const dt = new Date(value * 1000);
|
||||
const h = dt.getUTCHours();
|
||||
const m = dt.getUTCMinutes();
|
||||
const timeStr = String(h).padStart(2, '0') + ':' + String(m).padStart(2, '0');
|
||||
if (h === 0 && m === 0) {
|
||||
return [timeStr, dt.toLocaleDateString('en-GB', {day: '2-digit', month: 'short', timeZone: 'UTC'})];
|
||||
}
|
||||
return timeStr;
|
||||
},
|
||||
},
|
||||
grid: {color: gridColor},
|
||||
};
|
||||
|
||||
const freqAxis = {
|
||||
min: 0,
|
||||
title: {display: true, text: 'Frequency (MHz)', color: textColor},
|
||||
ticks: {color: textColor},
|
||||
grid: {display: false},
|
||||
};
|
||||
|
||||
const AMATEUR_BANDS = [
|
||||
{label: '160m', freq: 1.8},
|
||||
{label: '80m', freq: 3.5},
|
||||
{label: '60m', freq: 5.3515},
|
||||
{label: '40m', freq: 7.0},
|
||||
{label: '30m', freq: 10.1},
|
||||
{label: '20m', freq: 14.0},
|
||||
{label: '17m', freq: 18.068},
|
||||
{label: '15m', freq: 21.0},
|
||||
{label: '12m', freq: 24.89},
|
||||
{label: '10m', freq: 28.0},
|
||||
];
|
||||
|
||||
const bandLinesPlugin = {
|
||||
id: 'bandLines',
|
||||
beforeDatasetsDraw(chart) {
|
||||
const {ctx, chartArea, scales} = chart;
|
||||
if (!scales.y) return;
|
||||
ctx.save();
|
||||
ctx.strokeStyle = gridColor;
|
||||
ctx.lineWidth = 1;
|
||||
ctx.setLineDash([]);
|
||||
const y30 = scales.y.getPixelForValue(30);
|
||||
if (y30 >= chartArea.top && y30 <= chartArea.bottom) {
|
||||
ctx.beginPath();
|
||||
ctx.moveTo(chartArea.left, y30);
|
||||
ctx.lineTo(chartArea.right, y30);
|
||||
ctx.stroke();
|
||||
}
|
||||
ctx.font = '10px sans-serif';
|
||||
ctx.fillStyle = textColor;
|
||||
AMATEUR_BANDS.forEach(({label, freq}) => {
|
||||
const y = scales.y.getPixelForValue(freq);
|
||||
if (y < chartArea.top || y > chartArea.bottom) return;
|
||||
ctx.beginPath();
|
||||
ctx.moveTo(chartArea.left, y);
|
||||
ctx.lineTo(chartArea.right, y);
|
||||
ctx.stroke();
|
||||
ctx.textAlign = 'right';
|
||||
ctx.textBaseline = 'bottom';
|
||||
ctx.fillText(label, chartArea.right - 4, y - 2);
|
||||
});
|
||||
ctx.restore();
|
||||
}
|
||||
};
|
||||
|
||||
ionosondeChart = new Chart(document.getElementById('ionosonde-chart'), {
|
||||
type: 'line',
|
||||
data: {
|
||||
datasets: [
|
||||
{
|
||||
label: 'foF2',
|
||||
data: fof2Entries.map(e => ({x: e.ts, y: e.val})),
|
||||
borderColor: fof2Color,
|
||||
backgroundColor: 'transparent',
|
||||
pointRadius: 0,
|
||||
tension: 0.2,
|
||||
},
|
||||
{
|
||||
label: 'MUF (3000 km)',
|
||||
data: mufEntries.map(e => ({x: e.ts, y: e.val})),
|
||||
borderColor: mufColor,
|
||||
backgroundColor: 'transparent',
|
||||
pointRadius: 0,
|
||||
tension: 0.2,
|
||||
}
|
||||
]
|
||||
},
|
||||
options: {
|
||||
responsive: true,
|
||||
aspectRatio: 3,
|
||||
plugins: {
|
||||
legend: {display: true, labels: {color: textColor, usePointStyle: true, pointStyle: 'line'}},
|
||||
tooltip: {enabled: false}
|
||||
},
|
||||
scales: {x: timeAxis, y: freqAxis},
|
||||
},
|
||||
plugins: [bandLinesPlugin],
|
||||
});
|
||||
}
|
||||
|
||||
// Called when the ionosonde station select changes
|
||||
function ionosondeStationChanged() {
|
||||
saveSettings();
|
||||
renderIonosondeData();
|
||||
}
|
||||
|
||||
// Render the DX stats table for the currently selected DE continent
|
||||
function renderDxStats() {
|
||||
if (!dxStatsData) {
|
||||
|
||||
Reference in New Issue
Block a user