From ec0ec3af80a7e31be506baa0782b9aa955c02778 Mon Sep 17 00:00:00 2001 From: accius Date: Mon, 2 Feb 2026 20:11:33 -0500 Subject: [PATCH] aurora forecast --- server.js | 19 ++ src/plugins/layers/useAurora.js | 313 ++++++++++++++++++++++---------- 2 files changed, 236 insertions(+), 96 deletions(-) diff --git a/server.js b/server.js index e25b42e..213b9d0 100644 --- a/server.js +++ b/server.js @@ -285,6 +285,7 @@ const noaaCache = { kindex: { data: null, timestamp: 0 }, sunspots: { data: null, timestamp: 0 }, xray: { data: null, timestamp: 0 }, + aurora: { data: null, timestamp: 0 }, solarIndices: { data: null, timestamp: 0 } }; const NOAA_CACHE_TTL = 5 * 60 * 1000; // 5 minutes @@ -684,6 +685,24 @@ app.get('/api/noaa/xray', async (req, res) => { } }); +// NOAA OVATION Aurora Forecast +const AURORA_CACHE_TTL = 10 * 60 * 1000; // 10 minutes (NOAA updates every ~30 min) +app.get('/api/noaa/aurora', async (req, res) => { + try { + if (noaaCache.aurora.data && (Date.now() - noaaCache.aurora.timestamp) < AURORA_CACHE_TTL) { + return res.json(noaaCache.aurora.data); + } + const response = await fetch('https://services.swpc.noaa.gov/json/ovation_aurora_latest.json'); + const data = await response.json(); + noaaCache.aurora = { data, timestamp: Date.now() }; + res.json(data); + } catch (error) { + console.error('NOAA Aurora API error:', error.message); + if (noaaCache.aurora.data) return res.json(noaaCache.aurora.data); + res.status(500).json({ error: 'Failed to fetch aurora data' }); + } +}); + // POTA Spots // POTA cache (2 minutes) let potaCache = { data: null, timestamp: 0 }; diff --git a/src/plugins/layers/useAurora.js b/src/plugins/layers/useAurora.js index e3eaa18..995d5a4 100644 --- a/src/plugins/layers/useAurora.js +++ b/src/plugins/layers/useAurora.js @@ -1,129 +1,250 @@ -import { useState, useEffect } from 'react'; +import { useState, useEffect, useRef } from 'react'; -// NOAA OVATION Aurora Forecast -// Provides 30-min forecast of auroral activity as a global image overlay -// Data: https://services.swpc.noaa.gov/products/animations/ovation_north_24h.json -// Image: https://services.swpc.noaa.gov/images/animations/ovation/north/latest.jpg +// NOAA OVATION Aurora Forecast - JSON grid data +// Endpoint: /api/noaa/aurora (proxied from services.swpc.noaa.gov/json/ovation_aurora_latest.json) +// Format: { "Forecast Time": "...", "coordinates": [[lon, lat, probability], ...] } +// Grid: 360 longitudes (0-359) × 181 latitudes (-90 to 90), probability 0-100 export const metadata = { id: 'aurora', name: 'Aurora Forecast', - description: 'NOAA OVATION auroral oval forecast (Northern & Southern)', + description: 'NOAA OVATION aurora probability forecast (30-min)', icon: '🌌', category: 'space-weather', defaultEnabled: false, - defaultOpacity: 0.5, - version: '1.0.0' + defaultOpacity: 0.6, + version: '2.0.0' }; -export function useLayer({ enabled = false, opacity = 0.5, map = null }) { - const [northLayer, setNorthLayer] = useState(null); - const [southLayer, setSouthLayer] = useState(null); - const [refreshTimestamp, setRefreshTimestamp] = useState(Date.now()); +// Aurora color ramp: transparent → green → yellow → red +// Matches NOAA's official aurora visualization +function auroraCmap(probability) { + if (probability < 4) return null; // Skip very low values - // NOAA provides aurora forecast as a GeoJSON-like data product - // We'll use the OVATION aurora map images which cover both hemispheres - // These are pre-rendered transparent PNGs showing aurora probability + // Normalize 4-100 to 0-1 + const t = Math.min((probability - 4) / 80, 1); + let r, g, b, a; + if (t < 0.25) { + // Dark green to green + const s = t / 0.25; + r = 0; + g = Math.round(80 + s * 175); + b = Math.round(40 * (1 - s)); + a = 0.3 + s * 0.3; + } else if (t < 0.5) { + // Green to yellow-green + const s = (t - 0.25) / 0.25; + r = Math.round(s * 200); + g = 255; + b = 0; + a = 0.6 + s * 0.15; + } else if (t < 0.75) { + // Yellow to orange + const s = (t - 0.5) / 0.25; + r = 255; + g = Math.round(255 - s * 120); + b = 0; + a = 0.75 + s * 0.1; + } else { + // Orange to red + const s = (t - 0.75) / 0.25; + r = 255; + g = Math.round(135 - s * 135); + b = Math.round(s * 30); + a = 0.85 + s * 0.15; + } + + return { r, g, b, a }; +} + +function buildAuroraCanvas(coordinates) { + // Create a 360×181 canvas (1° resolution) + const canvas = document.createElement('canvas'); + canvas.width = 360; + canvas.height = 181; + const ctx = canvas.getContext('2d'); + + // Clear to transparent + ctx.clearRect(0, 0, 360, 181); + + const imageData = ctx.createImageData(360, 181); + const pixels = imageData.data; + + // Process all coordinate points + for (let i = 0; i < coordinates.length; i++) { + const [lon, lat, prob] = coordinates[i]; + if (prob < 4) continue; // Skip negligible + + const color = auroraCmap(prob); + if (!color) continue; + + // NOAA grid: lon 0-359, lat -90 to 90 + // Canvas: x = lon (0-359), y = 0 is +90 (north), y = 180 is -90 (south) + const x = Math.round(lon) % 360; + const y = 90 - Math.round(lat); // Flip: lat 90 → y 0, lat -90 → y 180 + + if (x < 0 || x >= 360 || y < 0 || y >= 181) continue; + + const idx = (y * 360 + x) * 4; + pixels[idx] = color.r; + pixels[idx + 1] = color.g; + pixels[idx + 2] = color.b; + pixels[idx + 3] = Math.round(color.a * 255); + } + + ctx.putImageData(imageData, 0, 0); + + // Scale up with a larger canvas for smoother rendering + const smoothCanvas = document.createElement('canvas'); + smoothCanvas.width = 720; + smoothCanvas.height = 362; + const sctx = smoothCanvas.getContext('2d'); + sctx.imageSmoothingEnabled = true; + sctx.imageSmoothingQuality = 'high'; + sctx.drawImage(canvas, 0, 0, 720, 362); + + return smoothCanvas.toDataURL('image/png'); +} + +export function useLayer({ enabled = false, opacity = 0.6, map = null }) { + const [overlayLayer, setOverlayLayer] = useState(null); + const [auroraData, setAuroraData] = useState(null); + const [forecastTime, setForecastTime] = useState(null); + const fetchingRef = useRef(false); + + // Fetch aurora JSON data useEffect(() => { - if (!map || typeof L === 'undefined') return; + if (!enabled) return; - if (enabled) { + const fetchAurora = async () => { + if (fetchingRef.current) return; + fetchingRef.current = true; try { - // NOAA OVATION aurora forecast - uses a tile overlay from SWPC - // The aurora oval images are projected onto a polar view, but SWPC also - // provides an equirectangular overlay we can use with Leaflet - const t = Math.floor(Date.now() / 300000) * 300000; // 5-min cache bust - - // Northern hemisphere aurora overlay - const north = L.imageOverlay( - `https://services.swpc.noaa.gov/images/aurora-forecast-northern-hemisphere.jpg?t=${t}`, - [[0, -180], [90, 180]], - { - opacity: opacity, - zIndex: 210, - className: 'aurora-overlay' + const res = await fetch('/api/noaa/aurora'); + if (res.ok) { + const data = await res.json(); + if (data.coordinates && data.coordinates.length > 0) { + setAuroraData(data.coordinates); + setForecastTime(data['Forecast Time'] || null); } - ); - - // Southern hemisphere aurora overlay - const south = L.imageOverlay( - `https://services.swpc.noaa.gov/images/aurora-forecast-southern-hemisphere.jpg?t=${t}`, - [[-90, -180], [0, 180]], - { - opacity: opacity, - zIndex: 210, - className: 'aurora-overlay' - } - ); - - north.addTo(map); - south.addTo(map); - setNorthLayer(north); - setSouthLayer(south); + } } catch (err) { - console.error('Aurora overlay error:', err); - } - } else { - // Remove layers when disabled - if (northLayer) { - try { map.removeLayer(northLayer); } catch (e) {} - setNorthLayer(null); + console.error('Aurora data fetch error:', err); + } finally { + fetchingRef.current = false; } - if (southLayer) { - try { map.removeLayer(southLayer); } catch (e) {} - setSouthLayer(null); + }; + + fetchAurora(); + // Refresh every 10 minutes + const interval = setInterval(fetchAurora, 600000); + return () => clearInterval(interval); + }, [enabled]); + + // Render overlay when data or map changes + useEffect(() => { + if (!map || typeof L === 'undefined') return; + + // Remove existing + if (overlayLayer) { + try { map.removeLayer(overlayLayer); } catch (e) {} + setOverlayLayer(null); + } + + if (!enabled || !auroraData) return; + + try { + const dataUrl = buildAuroraCanvas(auroraData); + + // NOAA grid: lon 0-359, lat -90 to 90 + // Leaflet bounds: [[south, west], [north, east]] + // Shift by 180° so 0° longitude is centered properly + // The data starts at lon=0 (Greenwich), so the image spans [0, 360) in longitude + // We need two overlays or shift the data. Simplest: overlay from -180 to 180 with shifted image. + // Actually, L.imageOverlay with bounds [[-90, 0], [90, 360]] works because Leaflet wraps. + // But for proper centering, let's use [[-90, -180], [90, 180]] and shift the canvas. + + // Build a shifted canvas where lon 0-179 goes to right half, lon 180-359 goes to left half + const shiftedCanvas = document.createElement('canvas'); + shiftedCanvas.width = 720; + shiftedCanvas.height = 362; + const sctx = shiftedCanvas.getContext('2d'); + sctx.imageSmoothingEnabled = true; + sctx.imageSmoothingQuality = 'high'; + + // Rebuild from raw data with shifted longitudes + const rawCanvas = document.createElement('canvas'); + rawCanvas.width = 360; + rawCanvas.height = 181; + const rctx = rawCanvas.getContext('2d'); + rctx.clearRect(0, 0, 360, 181); + const imageData = rctx.createImageData(360, 181); + const pixels = imageData.data; + + for (let i = 0; i < auroraData.length; i++) { + const [lon, lat, prob] = auroraData[i]; + if (prob < 4) continue; + + const color = auroraCmap(prob); + if (!color) continue; + + // Shift longitude: NOAA 0-359 → map -180 to 179 + let x = Math.round(lon); + x = x >= 180 ? x - 180 : x + 180; // Shift so -180 maps to pixel 0 + x = x % 360; + + const y = 90 - Math.round(lat); + if (x < 0 || x >= 360 || y < 0 || y >= 181) continue; + + const idx = (y * 360 + x) * 4; + pixels[idx] = color.r; + pixels[idx + 1] = color.g; + pixels[idx + 2] = color.b; + pixels[idx + 3] = Math.round(color.a * 255); } + + rctx.putImageData(imageData, 0, 0); + sctx.drawImage(rawCanvas, 0, 0, 720, 362); + + const shiftedUrl = shiftedCanvas.toDataURL('image/png'); + + const overlay = L.imageOverlay( + shiftedUrl, + [[-90, -180], [90, 180]], + { + opacity: opacity, + zIndex: 210, + interactive: false + } + ); + + overlay.addTo(map); + setOverlayLayer(overlay); + } catch (err) { + console.error('Aurora overlay render error:', err); } return () => { - if (northLayer && map) { - try { map.removeLayer(northLayer); } catch (e) {} - } - if (southLayer && map) { - try { map.removeLayer(southLayer); } catch (e) {} + if (overlayLayer && map) { + try { map.removeLayer(overlayLayer); } catch (e) {} } }; - }, [enabled, map, refreshTimestamp]); + }, [enabled, auroraData, map]); // Update opacity useEffect(() => { - if (northLayer) northLayer.setOpacity(opacity); - if (southLayer) southLayer.setOpacity(opacity); - }, [opacity, northLayer, southLayer]); - - // Auto-refresh every 10 minutes (NOAA updates ~every 30 min) - useEffect(() => { - if (!enabled) return; - - const interval = setInterval(() => { - // Remove old layers and trigger re-add with new timestamp - if (northLayer && map) { - try { map.removeLayer(northLayer); } catch (e) {} - setNorthLayer(null); - } - if (southLayer && map) { - try { map.removeLayer(southLayer); } catch (e) {} - setSouthLayer(null); - } - setRefreshTimestamp(Date.now()); - }, 600000); // 10 minutes - - return () => clearInterval(interval); - }, [enabled, northLayer, southLayer, map]); + if (overlayLayer) { + overlayLayer.setOpacity(opacity); + } + }, [opacity, overlayLayer]); return { - layers: [northLayer, southLayer].filter(Boolean), + layer: overlayLayer, + forecastTime, refresh: () => { - if (northLayer && map) { - try { map.removeLayer(northLayer); } catch (e) {} - setNorthLayer(null); - } - if (southLayer && map) { - try { map.removeLayer(southLayer); } catch (e) {} - setSouthLayer(null); - } - setRefreshTimestamp(Date.now()); + setAuroraData(null); + fetchingRef.current = false; } }; }