1// spaceWeather.js - Fetch and display space weather data (Kp index, solar wind, magnetic field) 2 3/** 4 * Generate mock Kp data for testing when API is unavailable 5 * Includes both historical and forecast data 6 */ 7function generateMockKpData() { 8 const data = []; 9 const now = new Date(); 10 // Generate 24 hours past data (8 three-hour intervals) 11 for (let i = -7; i <= 0; i++) { 12 const time = new Date(now.getTime() + i * 3 * 60 * 60 * 1000); 13 const kp = Math.max(0, Math.min(9, 3 + Math.random() * 4 - 1 + (i === 0 ? 1 : 0))); 14 data.push({ 15 time, 16 kp: Math.round(kp * 10) / 10, 17 observed: true 18 }); 19 } 20 // Generate 24 hours future data (8 three-hour intervals for forecast) 21 for (let i = 1; i <= 8; i++) { 22 const time = new Date(now.getTime() + i * 3 * 60 * 60 * 1000); 23 const kp = Math.max(0, Math.min(9, 3 + Math.random() * 3 - 0.5)); 24 data.push({ 25 time, 26 kp: Math.round(kp * 10) / 10, 27 observed: false // forecast 28 }); 29 } 30 return data; 31} 32 33/** 34 * Generate mock solar wind data 35 */ 36function generateMockSolarWindData() { 37 const data = []; 38 const now = new Date(); 39 // Generate 24 hours of data 40 for (let i = 0; i < 144; i++) { // Every 10 minutes 41 const time = new Date(now.getTime() - (144 - i) * 10 * 60 * 1000); 42 const speed = 350 + Math.random() * 200 + Math.sin(i / 20) * 100; 43 data.push({ 44 time, 45 density: 5 + Math.random() * 5, 46 speed: Math.round(speed), 47 temperature: 100000 + Math.random() * 50000 48 }); 49 } 50 return data; 51} 52 53/** 54 * Generate mock magnetic field data 55 */ 56function generateMockMagneticFieldData() { 57 const data = []; 58 const now = new Date(); 59 // Generate 24 hours of data 60 for (let i = 0; i < 144; i++) { // Every 10 minutes 61 const time = new Date(now.getTime() - (144 - i) * 10 * 60 * 1000); 62 const bz = -10 + Math.random() * 20 + Math.sin(i / 15) * 8; 63 data.push({ 64 time, 65 bx: Math.random() * 10 - 5, 66 by: Math.random() * 10 - 5, 67 bz: Math.round(bz * 10) / 10, 68 lon: 0, 69 lat: 0 70 }); 71 } 72 return data; 73} 74 75function toDate(value) { 76 const date = value instanceof Date ? value : new Date(value); 77 return Number.isNaN(date.getTime()) ? null : date; 78} 79 80function toNumber(value) { 81 const num = typeof value === 'number' ? value : parseFloat(value); 82 return Number.isFinite(num) ? num : null; 83} 84 85export function parseKpRows(payload, source) { 86 if (!Array.isArray(payload)) return []; 87 88 // Legacy format: [ ["time_tag", "kp"], ["2026-...", "3.67"], ... ] 89 if (payload.length > 0 && Array.isArray(payload[0])) { 90 return payload 91 .slice(1) 92 .map((row) => { 93 const time = toDate(row?.[0]); 94 const kp = toNumber(row?.[1]); 95 if (!time || kp === null) return null; 96 return { 97 time, 98 kp, 99 observed: source === 'observed' ? true : row?.[2] !== 'predicted' 100 }; 101 }) 102 .filter(Boolean); 103 } 104 105 // Current format: [ {time_tag, Kp/kp, observed}, ... ] 106 return payload 107 .map((row) => { 108 const time = toDate(row?.time_tag ?? row?.time ?? row?.timestamp); 109 const kp = toNumber(row?.Kp ?? row?.kp ?? row?.kP); 110 if (!time || kp === null) return null; 111 112 const observedLabel = String(row?.observed ?? '').toLowerCase(); 113 const observed = source === 'observed' 114 ? true 115 : (observedLabel ? observedLabel !== 'predicted' : false); 116 117 return { time, kp, observed }; 118 }) 119 .filter(Boolean); 120} 121 122 123// --------------------------------------------------------------------------- 124// Parsers 125// --------------------------------------------------------------------------- 126// Each parser is pure and exported so the wire formats stay under test without 127// needing the network. Every one returns [] rather than throwing on junk input. 128 129function toUtcDate(raw) { 130 if (typeof raw !== 'string') return toDate(raw); 131 // RTSW timestamps are UTC but omit the zone suffix; without this they'd be 132 // read as local time and the charts would shift by the viewer's offset. 133 return toDate(raw.endsWith('Z') ? raw : `${raw}Z`); 134} 135 136/** 137 * Parse the bundle published by the updateSpaceWeatherData function. 138 * Shape: { mag: [[t, bx, by, bz, bt], ...], plasma: [[t, density, speed, temp], ...] } 139 */ 140export function parseSolarWindBundle(payload) { 141 const empty = { mag: [], plasma: [] }; 142 if (!payload || typeof payload !== 'object') return empty; 143 144 const mag = (Array.isArray(payload.mag) ? payload.mag : []) 145 .map((row) => { 146 if (!Array.isArray(row)) return null; 147 const time = toUtcDate(row[0]); 148 const bz = toNumber(row[3]); 149 if (!time || bz === null) return null; 150 return { time, bx: toNumber(row[1]), by: toNumber(row[2]), bz, bt: toNumber(row[4]) }; 151 }) 152 .filter(Boolean); 153 154 const plasma = (Array.isArray(payload.plasma) ? payload.plasma : []) 155 .map((row) => { 156 if (!Array.isArray(row)) return null; 157 const time = toUtcDate(row[0]); 158 const speed = toNumber(row[2]); 159 if (!time || speed === null) return null; 160 return { time, density: toNumber(row[1]), speed, temperature: toNumber(row[3]) }; 161 }) 162 .filter(Boolean); 163 164 return { mag, plasma }; 165} 166 167/** Parse NOAA's raw RTSW magnetometer feed (the direct-from-NOAA fallback). */ 168export function parseRtswMag(payload) { 169 if (!Array.isArray(payload)) return []; 170 return payload 171 .map((row) => { 172 const time = toUtcDate(row?.time_tag); 173 const bz = toNumber(row?.bz_gsm); 174 if (!time || bz === null) return null; 175 return { time, bx: toNumber(row?.bx_gsm), by: toNumber(row?.by_gsm), bz, bt: toNumber(row?.bt) }; 176 }) 177 .filter(Boolean) 178 // RTSW arrives newest-first; charts expect oldest-first. 179 .sort((a, b) => a.time - b.time); 180} 181 182/** Parse NOAA's raw RTSW plasma feed (the direct-from-NOAA fallback). */ 183export function parseRtswWind(payload) { 184 if (!Array.isArray(payload)) return []; 185 return payload 186 .map((row) => { 187 const time = toUtcDate(row?.time_tag); 188 const speed = toNumber(row?.proton_speed); 189 if (!time || speed === null) return null; 190 return { 191 time, 192 density: toNumber(row?.proton_density), 193 speed, 194 temperature: toNumber(row?.proton_temperature), 195 }; 196 }) 197 .filter(Boolean) 198 .sort((a, b) => a.time - b.time); 199} 200 201// --------------------------------------------------------------------------- 202// Transport 203// --------------------------------------------------------------------------- 204// NOAA retired /products/solar-wind/{mag,plasma}-1-day.json. Its 404 page sends 205// no Access-Control-Allow-Origin, so the browser reported the dead endpoint as a 206// CORS failure. The live replacements are the RTSW 1-minute feeds, but those are 207// ~4 MB combined, so we normally read the small normalised bundle that 208// updateSpaceWeatherData publishes to our bucket every 5 minutes and only fall 209// back to NOAA directly if the mirror is unreachable. 210const MIRROR_BASE = 'https://storage.googleapis.com/auroramap-public-data/spaceweather'; 211const SOLAR_WIND_BUNDLE_URL = `${MIRROR_BASE}/solar-wind-1-day.json`; 212const RTSW_MAG_URL = 'https://services.swpc.noaa.gov/json/rtsw/rtsw_mag_1m.json'; 213const RTSW_WIND_URL = 'https://services.swpc.noaa.gov/json/rtsw/rtsw_wind_1m.json'; 214const KP_SOURCES = { 215 observed: [`${MIRROR_BASE}/noaa-planetary-k-index.json`, 'https://services.swpc.noaa.gov/products/noaa-planetary-k-index.json'], 216 forecast: [`${MIRROR_BASE}/noaa-planetary-k-index-forecast.json`, 'https://services.swpc.noaa.gov/products/noaa-planetary-k-index-forecast.json'], 217}; 218 219const FETCH_TIMEOUT_MS = 8000; 220 221function markMock(data, reason) { 222 // Non-enumerable so chart code and JSON.stringify never trip over it, but 223 // callers (and tests) can still tell fabricated data from real data. 224 Object.defineProperty(data, 'isMock', { value: true, enumerable: false });
225 Object.defineProperty(data, 'mockReason', { value: reason, enumerable: false }); 226 227 if (typeof window !== 'undefined' && typeof window.dispatchEvent === 'function') { 228 window.dispatchEvent(new CustomEvent('spaceweather-degraded', { detail: { reason } })); 229 } 230 return data; 231} 232 233async function fetchJson(url) { 234 // AbortSignal.timeout() is missing on older Safari, so drive a controller. 235 const controller = new AbortController(); 236 const timer = setTimeout(() => controller.abort(), FETCH_TIMEOUT_MS); 237 try { 238 const response = await fetch(url, { signal: controller.signal }); 239 if (!response.ok) throw new Error(`HTTP ${response.status}`); 240 return await response.json(); 241 } finally { 242 clearTimeout(timer); 243 } 244} 245 246/** Try each URL in order; resolve with the first that works. */ 247async function fetchFirstAvailable(urls, label) { 248 let lastError; 249 for (const url of urls) { 250 try { 251 return await fetchJson(url); 252 } catch (error) { 253 lastError = error; 254 console.warn(`[spaceWeather] ${label}: ${url} failed (${error.message})`); 255 } 256 } 257 throw lastError || new Error(`No source available for ${label}`); 258} 259 260// fetchSolarWind() and fetchMagneticField() both read the same bundle and are 261// normally called together, so memoise the in-flight request to avoid fetching 262// it twice per page load. 263let solarWindPromise = null; 264 265function loadSolarWind() { 266 if (solarWindPromise) return solarWindPromise; 267 268 solarWindPromise = (async () => { 269 try { 270 return parseSolarWindBundle(await fetchJson(SOLAR_WIND_BUNDLE_URL)); 271 } catch (bundleError) { 272 console.warn(`[spaceWeather] mirror unavailable (${bundleError.message}); falling back to NOAA RTSW`); 273 const [magResult, windResult] = await Promise.allSettled([ 274 fetchJson(RTSW_MAG_URL), 275 fetchJson(RTSW_WIND_URL), 276 ]); 277 const result = { 278 mag: magResult.status === 'fulfilled' ? parseRtswMag(magResult.value) : [], 279 plasma: windResult.status === 'fulfilled' ? parseRtswWind(windResult.value) : [], 280 }; 281 282 // Reject rather than resolving with two empty series: resolving 283 // would memoise the outage and pin the page to mock data for the 284 // rest of the session instead of retrying on the next call. 285 if (result.mag.length === 0 && result.plasma.length === 0) { 286 throw new Error(`no solar wind data from mirror or NOAA (${bundleError.message})`); 287 } 288 return result; 289 } 290 })(); 291 292 // A failed load must not be cached forever â let the next caller retry. 293 solarWindPromise.catch(() => { solarWindPromise = null; }); 294 return solarWindPromise; 295} 296 297/** Drop the memoised solar wind response. Exported for tests and manual refresh. */ 298export function resetSolarWindCache() { 299 solarWindPromise = null; 300} 301 302// --------------------------------------------------------------------------- 303// Public API 304// --------------------------------------------------------------------------- 305 306/** 307 * Fetch Kp index data (historical merged with forecast). 308 * Returns array of {time, kp, observed}, sorted oldest-first. 309 * Falls back to mock data tagged with a non-enumerable `isMock` flag. 310 */ 311export async function fetchKpIndex() { 312 try { 313 const [observedResult, forecastResult] = await Promise.allSettled([ 314 fetchFirstAvailable(KP_SOURCES.observed, 'kp observed'), 315 fetchFirstAvailable(KP_SOURCES.forecast, 'kp forecast'), 316 ]); 317 318 const observed = observedResult.status === 'fulfilled' 319 ? parseKpRows(observedResult.value, 'observed') 320 : []; 321 const forecast = forecastResult.status === 'fulfilled' 322 ? parseKpRows(forecastResult.value, 'forecast') 323 : []; 324 325 // Observed wins over forecast wherever both cover the same timestamp. 326 const mergedByTime = new Map(); 327 observed.forEach(entry => mergedByTime.set(entry.time.toISOString(), entry)); 328 forecast.forEach(entry => {
329 const key = entry.time.toISOString(); 330 if (!mergedByTime.has(key)) mergedByTime.set(key, entry); 331 }); 332 333 const data = Array.from(mergedByTime.values()); 334 if (data.length === 0) throw new Error('no Kp rows from any source'); 335 336 data.sort((a, b) => a.time - b.time); 337 return data; 338 } catch (error) { 339 console.warn('[spaceWeather] using mock Kp data:', error.message); 340 return markMock(generateMockKpData(), `kp: ${error.message}`); 341 } 342} 343 344/** 345 * Fetch solar wind magnetic field data (Bz in GSM). 346 * Returns array of {time, bx, by, bz, bt}. 347 */ 348export async function fetchMagneticField() { 349 try { 350 const { mag } = await loadSolarWind(); 351 if (mag.length === 0) throw new Error('no magnetometer rows'); 352 return mag; 353 } catch (error) { 354 console.warn('[spaceWeather] using mock magnetic field data:', error.message); 355 return markMock(generateMockMagneticFieldData(), `mag: ${error.message}`); 356 } 357} 358 359/** 360 * Fetch solar wind plasma data (speed, density, temperature). 361 * Returns array of {time, density, speed, temperature}. 362 */ 363export async function fetchSolarWind() { 364 try { 365 const { plasma } = await loadSolarWind(); 366 if (plasma.length === 0) throw new Error('no plasma rows'); 367 return plasma; 368 } catch (error) { 369 console.warn('[spaceWeather] using mock solar wind data:', error.message); 370 return markMock(generateMockSolarWindData(), `plasma: ${error.message}`); 371 } 372} 373 374/** 375 * Fetch the 3-day Kp forecast. Returns [] when unavailable â an empty forecast 376 * means "nothing to draw" rather than substituting invented values. 377 */ 378export async function fetchKpForecast() { 379 try { 380 const payload = await fetchFirstAvailable(KP_SOURCES.forecast, 'kp forecast'); 381 return parseKpRows(payload, 'forecast').filter(entry => entry.observed === false); 382 } catch (error) { 383 console.warn('[spaceWeather] Kp forecast unavailable:', error.message); 384 return []; 385 } 386}
Line numbers count LF bytes from the start of the resource, as the search results do. Vendor segments are library code the classifier recognised; they are stored but not indexed. Bytes are shown as Latin1 characters, one per byte.