You can not select more than 25 topics Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.

251 lines
7.3 KiB

This file contains ambiguous Unicode characters!

This file contains ambiguous Unicode characters that may be confused with others in your current locale. If your use case is intentional and legitimate, you can safely ignore this warning. Use the Escape button to highlight these characters.

import { useState, useEffect, useRef } from 'react';
// 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 aurora probability forecast (30-min)',
icon: '🌌',
category: 'space-weather',
defaultEnabled: false,
defaultOpacity: 0.6,
version: '2.0.0'
};
// 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
// 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 (!enabled) return;
const fetchAurora = async () => {
if (fetchingRef.current) return;
fetchingRef.current = true;
try {
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);
}
}
} catch (err) {
console.error('Aurora data fetch error:', err);
} finally {
fetchingRef.current = false;
}
};
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 (overlayLayer && map) {
try { map.removeLayer(overlayLayer); } catch (e) {}
}
};
}, [enabled, auroraData, map]);
// Update opacity
useEffect(() => {
if (overlayLayer) {
overlayLayer.setOpacity(opacity);
}
}, [opacity, overlayLayer]);
return {
layer: overlayLayer,
forecastTime,
refresh: () => {
setAuroraData(null);
fetchingRef.current = false;
}
};
}

Powered by TurnKey Linux.