aurora forecast

pull/45/head
accius 2 days ago
parent dad591e265
commit ec0ec3af80

@ -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 };

@ -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;
}
};
}

Loading…
Cancel
Save

Powered by TurnKey Linux.