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