// Cache for the full dxstats API response, so we can reload on the fly if the user changes the value of their continent // in the select box let dxStatsData = null; // Forecast chart let kpChart = null; // Load solar conditions function loadSolarConditions() { $.getJSON('/api/v1/solar', function (jsonData) { // HF const hfConditionClass = {'Good': 'bg-success-subtle', 'Fair': 'bg-warning-subtle', 'Poor': 'bg-danger-subtle'}; if (jsonData.hf_conditions) { Object.entries(jsonData.hf_conditions).forEach(function ([key, condition]) { const cell = $('#hf-conditions-' + key); cell.text(condition); cell.addClass(hfConditionClass[condition]); }); } // VHF if (jsonData.vhf_conditions) { Object.entries(jsonData.vhf_conditions).forEach(function ([key, condition]) { const cell = $('#vhf-conditions-' + key); cell.text(condition); let vhfClass; if (condition === 'Band Closed') { vhfClass = 'bg-danger-subtle'; } else if (condition.includes('High')) { vhfClass = 'bg-warning-subtle'; } else { vhfClass = 'bg-success-subtle'; } cell.addClass(vhfClass); }); } if (jsonData.aurora_latitude !== null && jsonData.aurora_latitude !== undefined) { $('#vhf-conditions-aurora-lat').text(jsonData.aurora_latitude + '°'); } // Solar Weather const swFields = { 'sfi': 'sw-sfi', 'sunspots': 'sw-sunspots', 'band_conditions_desc': 'sw-solar-flux-desc', 'k_index': 'sw-k-index', 'a_index': 'sw-a-index', 'geomag_field': 'sw-geomag-field', 'geomag_storm_scale': 'sw-geomag-storm-scale', 'geomag_storm_desc': 'sw-geomag-storm-desc', 'geomag_noise': 'sw-geomag-noise', 'x_ray': 'sw-x-ray', 'blackout_desc': 'sw-xray-desc', 'proton_flux': 'sw-proton-flux', 'solar_storm_scale': 'sw-solar-storm-scale', 'proton_flux_desc': 'sw-proton-desc', 'electron_flux': 'sw-electron-flux', 'electron_flux_desc': 'sw-electron-desc', }; Object.entries(swFields).forEach(function ([field, id]) { const val = jsonData[field]; if (val !== null && val !== undefined) { $('#' + id).text(val); } }); // Solar Weather - colouring function applySwClass(valsId, descId, cls) { $('#' + valsId).addClass(cls); $('#' + descId).addClass(cls); } const sfi = jsonData.sfi; if (sfi !== null && sfi !== undefined) { applySwClass('sw-solar-flux-vals', 'sw-solar-flux-desc', sfi > 120 ? 'bg-success-subtle' : sfi > 90 ? 'bg-warning-subtle' : 'bg-danger-subtle'); } const kIndex = jsonData.k_index; if (kIndex !== null && kIndex !== undefined) { applySwClass('sw-geomag-vals', 'sw-geomag-desc', kIndex < 5 ? 'bg-success-subtle' : kIndex < 6 ? 'bg-warning-subtle' : 'bg-danger-subtle'); } const xRay = jsonData.x_ray; if (xRay) { const letter = xRay[0].toUpperCase(); const xRayClass = (letter === 'X') ? 'bg-danger-subtle' : (letter === 'M') ? 'bg-warning-subtle' : 'bg-success-subtle'; applySwClass('sw-xray-vals', 'sw-xray-desc', xRayClass); } const protonFlux = jsonData.proton_flux; if (protonFlux !== null && protonFlux !== undefined) { applySwClass('sw-proton-vals', 'sw-proton-desc', protonFlux <= 100 ? 'bg-success-subtle' : protonFlux <= 10000 ? 'bg-warning-subtle' : 'bg-danger-subtle'); } const electronFlux = jsonData.electron_flux; if (electronFlux !== null && electronFlux !== undefined) { applySwClass('sw-electron-vals', 'sw-electron-desc', electronFlux <= 100 ? 'bg-success-subtle' : electronFlux <= 1000 ? 'bg-warning-subtle' : 'bg-danger-subtle'); } // Forecast renderKIndexForecast(jsonData.k_index_forecast); renderSolarStormForecast(jsonData.solar_storm_forecast); renderBlackoutForecast(jsonData.blackout_forecast_r1r2, jsonData.blackout_forecast_r3_or_greater); }); } // Render the K-index forecast as a Chart.js bar chart, one bar per 3-hour UTC period function renderKIndexForecast(data) { if (!data) return; const entries = Object.entries(data) .map(([tsStr, kp]) => ({ts: parseFloat(tsStr), kp})) .sort((a, b) => a.ts - b.ts); if (entries.length === 0) return; // Use a simple integer index axis: ticks at 0, 1, 2, ..., N (period boundaries) and bars // centred at 0.5, 1.5, ..., N-0.5 (midpoints). This guarantees tick marks fall exactly on // bar edges regardless of how Chart.js rounds large timestamp values. // "axisMin = 0" is the left/top edge of bar 0; "axisMax = N" is the right/bottom edge of bar N-1. const N = entries.length; const periodSecs = 3 * 3600; // Inherit colours from Bootstrap CSS variables so that dark mode inherently works. We want bar colours that are not // quite as saturated as the Bootstrap success/warning/danger colours but not as desaturated as the "subtle" // versions, so use tinycolor to apply some transparency. const style = getComputedStyle(document.documentElement); const withAlpha = hex => tinycolor(hex).setAlpha(0.8).toRgbString(); const colors = entries.map(e => e.kp < 4.5 ? withAlpha(style.getPropertyValue('--bs-success').trim()) : e.kp < 5.5 ? withAlpha(style.getPropertyValue('--bs-warning').trim()) : withAlpha(style.getPropertyValue('--bs-danger').trim()) ); const textColor = style.getPropertyValue('--bs-body-color').trim() || '#666'; const gridColor = style.getPropertyValue('--bs-border-color').trim() || 'rgba(128,128,128,0.3)'; if (kpChart) { kpChart.destroy(); } const isMobile = window.innerWidth < 768; const kpAxisTicks = { stepSize: 1, color: textColor, // Include geomagnetic storm levels (Gx) alongside the Kp index callback: v => v > 4 ? `(G${v - 4}) ${v}` : String(v), }; const kpAxis = { min: 0, max: 9, title: {display: true, text: 'Kp', color: textColor}, ticks: kpAxisTicks, grid: {color: gridColor}, }; // Linear scale using integer indices. Ticks at 0..N (period boundary indices); // the callback converts each integer index back to a UTC time string. // On mobile the time axis is vertical, so reverse it to keep time running top-to-bottom. const timeAxis = { type: 'linear', min: 0, max: N, offset: false, reverse: isMobile, title: {display: true, text: 'Time (UTC)', color: textColor}, ticks: { stepSize: 1, color: textColor, maxRotation: 45, minRotation: 0, callback(value) { if (!Number.isInteger(value) || value < 0 || value > N) return null; const ts = value < N ? entries[value].ts : entries[N - 1].ts + periodSecs; const dt = new Date(ts * 1000); const h = dt.getUTCHours(), m = dt.getUTCMinutes(); const timeStr = String(h).padStart(2, '0') + ':' + String(m).padStart(2, '0'); if (h === 0 && m === 0) { return [timeStr, dt.toLocaleDateString('en-GB', {day: '2-digit', month: 'short', timeZone: 'UTC'})]; } return timeStr; }, }, grid: {color: gridColor}, }; // Draw a "now" line at the current time position const nowLinePlugin = { id: 'nowLine', afterDraw(chart) { const nowTs = Date.now() / 1000; // Find which bar (if any) the current time falls in and compute a fractional index const firstTs = entries[0].ts; const lastTs = entries[N - 1].ts + periodSecs; if (nowTs < firstTs || nowTs > lastTs) return; const fracIndex = (nowTs - firstTs) / periodSecs; const {ctx, chartArea} = chart; const scale = isMobile ? chart.scales.y : chart.scales.x; const pos = scale.getPixelForValue(fracIndex); ctx.save(); ctx.strokeStyle = textColor; ctx.lineWidth = 2; ctx.setLineDash([5, 4]); ctx.beginPath(); if (isMobile) { ctx.moveTo(chartArea.left, pos); ctx.lineTo(chartArea.right, pos); } else { ctx.moveTo(pos, chartArea.top); ctx.lineTo(pos, chartArea.bottom); } ctx.stroke(); ctx.setLineDash([]); ctx.fillStyle = textColor; ctx.font = '11px sans-serif'; if (isMobile) { ctx.textAlign = 'right'; ctx.textBaseline = 'bottom'; ctx.fillText('Now', chartArea.right, pos - 3); } else { ctx.textAlign = 'left'; ctx.textBaseline = 'top'; ctx.fillText(' Now', pos, chartArea.top + 3); } ctx.restore(); } }; // Bars centred at i+0.5 (midpoint between tick i and tick i+1) so each bar spans // exactly from tick i to tick i+1 with barPercentage/categoryPercentage = 1.0. const chartData = isMobile ? entries.map((e, i) => ({x: e.kp, y: i + 0.5})) : entries.map((e, i) => ({x: i + 0.5, y: e.kp})); kpChart = new Chart(document.getElementById('forecast-kp-chart'), { type: 'bar', data: { datasets: [{ data: chartData, backgroundColor: colors, hoverBackgroundColor: colors, borderWidth: 0, barPercentage: 1.0, categoryPercentage: 1.0, }] }, options: { responsive: true, // Swap axes on mobile, and change the aspect ratio aspectRatio: isMobile ? 0.4 : 3, indexAxis: isMobile ? 'y' : 'x', plugins: { legend: { display: false }, tooltip: { enabled: false } }, scales: { x: isMobile ? kpAxis : timeAxis, y: isMobile ? timeAxis : kpAxis, } }, plugins: [nowLinePlugin], }); } // Render the solar storm forecast table function renderSolarStormForecast(data) { if (!data) return; const entries = Object.entries(data) .map(([tsStr, pct]) => ({ts: parseFloat(tsStr), pct})) .sort((a, b) => a.ts - b.ts); // Header const headRow = $('#forecast-solar-storm-head').empty().append(''); entries.forEach(({ts}) => { const label = new Date(ts * 1000) .toLocaleDateString('en-GB', {day: '2-digit', month: 'short', timeZone: 'UTC'}); headRow.append(`${label}`); }); // Single data row: "S1 or greater" label + one cell per date const tr = $('').append('S1 or greater'); entries.forEach(({pct}) => { const 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(''); entries.forEach(({ts}) => { const label = new Date(ts * 1000) .toLocaleDateString('en-GB', {day: '2-digit', month: 'short', timeZone: 'UTC'}); headRow.append(`${label}`); }); // Two data rows: R1-R2 and R3+ function makeRow(rowLabel, getValue) { const tr = $('').append(`${rowLabel}`); entries.forEach(entry => { const pct = getValue(entry); const td = $(''); if (pct !== undefined) { td.text(pct + '%'); td.addClass(pct < 50 ? 'bg-success-subtle' : pct < 75 ? 'bg-warning-subtle' : 'bg-danger-subtle'); } tr.append(td); }); return tr; } $('#forecast-blackout-tbody').empty() .append(makeRow('R1-R2', e => e.r1r2)) .append(makeRow('R3 or greater', e => e.r3)); } // Render the DX stats table for the currently selected DE continent function renderDxStats() { if (!dxStatsData) { return; } const deContinent = $('#dxstats-de-continent').val(); const deData = dxStatsData[deContinent]; if (!deData) { return; } const cells = []; Object.entries(deData).forEach(function ([dxContinent, bands]) { Object.entries(bands).forEach(function ([band, count]) { const cell = $('#dxstats-' + dxContinent + '-' + band); cell.text(count); cells.push({cell, count}); }); }); const counts = cells.map(function (c) { return c.count; }); const min = Math.min(...counts); const max = Math.max(...counts); const range = max - min; cells.forEach(function ({cell, count}) { const t = range > 0 ? (count - min) / range : 0; const cls = t === 0 ? 'bg-danger-subtle' : t < 0.05 ? 'bg-warning-subtle' : 'bg-success-subtle'; cell.removeClass('bg-danger-subtle bg-warning-subtle bg-success-subtle').addClass(cls); }); } // Called when the DE continent select changes function dxStatsContientChanged() { saveSettings(); renderDxStats(); } // Fetch DX stats from the API and render function loadDxStats() { $.getJSON('/api/v1/dxstats', function (jsonData) { dxStatsData = jsonData; renderDxStats(); }); } // Startup $(document).ready(function () { loadSettings(); loadSolarConditions(); loadDxStats(); });