PageSourceSearch

https://aurora-c9c22.web.app/js/spaceWeather.js?v=a3f38c6c95

js aurora-c9c22.web.app collected 2026-10-03 12:17:53 UTC 14,699 bytes, 386 lines download raw bytes

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.