// 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; // 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('