From 1b22763e475505aa0c52c3aaef001923f421bde8 Mon Sep 17 00:00:00 2001 From: accius Date: Mon, 2 Feb 2026 19:48:20 -0500 Subject: [PATCH 1/4] blue ocean for nightmare of a map --- src/components/WorldMap.jsx | 2 +- src/utils/config.js | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/src/components/WorldMap.jsx b/src/components/WorldMap.jsx index 6332915..4bfb23f 100644 --- a/src/components/WorldMap.jsx +++ b/src/components/WorldMap.jsx @@ -648,7 +648,7 @@ export const WorldMap = ({ return (
-
+
{/* Render all plugin layers */} {mapInstanceRef.current && getAllLayers().map(layerDef => ( diff --git a/src/utils/config.js b/src/utils/config.js index 255cb2a..715a633 100644 --- a/src/utils/config.js +++ b/src/utils/config.js @@ -200,7 +200,7 @@ export const MAP_STYLES = { }, countries: { name: 'Countries', - url: 'https://server.arcgisonline.com/ArcGIS/rest/services/Canvas/World_Light_Gray_Base/MapServer/tile/{z}/{y}/{x}', + url: 'https://server.arcgisonline.com/ArcGIS/rest/services/Reference/World_Boundaries_and_Places/MapServer/tile/{z}/{y}/{x}', attribution: '© Esri, Natural Earth', countriesOverlay: true } From b28727f131443d6fb4d6804a750a9e1f64cf93d7 Mon Sep 17 00:00:00 2001 From: accius Date: Mon, 2 Feb 2026 19:57:37 -0500 Subject: [PATCH 2/4] xray module --- server.js | 7 +- src/components/SolarPanel.jsx | 243 +++++++++++++++++++++++++++++++--- 2 files changed, 230 insertions(+), 20 deletions(-) diff --git a/server.js b/server.js index 3327460..e25b42e 100644 --- a/server.js +++ b/server.js @@ -670,11 +670,16 @@ app.get('/api/dxpeditions', async (req, res) => { // NOAA Space Weather - X-Ray Flux app.get('/api/noaa/xray', async (req, res) => { try { - const response = await fetch('https://services.swpc.noaa.gov/json/goes/primary/xrays-7-day.json'); + if (noaaCache.xray.data && (Date.now() - noaaCache.xray.timestamp) < NOAA_CACHE_TTL) { + return res.json(noaaCache.xray.data); + } + const response = await fetch('https://services.swpc.noaa.gov/json/goes/primary/xrays-6-hour.json'); const data = await response.json(); + noaaCache.xray = { data, timestamp: Date.now() }; res.json(data); } catch (error) { console.error('NOAA X-Ray API error:', error.message); + if (noaaCache.xray.data) return res.json(noaaCache.xray.data); res.status(500).json({ error: 'Failed to fetch X-ray data' }); } }); diff --git a/src/components/SolarPanel.jsx b/src/components/SolarPanel.jsx index 762d360..cb98a78 100644 --- a/src/components/SolarPanel.jsx +++ b/src/components/SolarPanel.jsx @@ -1,25 +1,81 @@ /** * SolarPanel Component - * Toggleable between live sun image from NASA SDO and solar indices display + * Cycles between: Solar Image → Solar Indices → X-Ray Flux Chart */ -import React, { useState } from 'react'; +import React, { useState, useEffect, useCallback } from 'react'; + +const MODES = ['image', 'indices', 'xray']; +const MODE_LABELS = { image: 'SOLAR', indices: 'SOLAR INDICES', xray: 'X-RAY FLUX' }; +const MODE_ICONS = { image: '📊', indices: '📈', xray: '🖼️' }; +const MODE_TITLES = { image: 'Show solar indices', indices: 'Show X-ray flux', xray: 'Show solar image' }; + +// Flare class from flux value (W/m²) +const getFlareClass = (flux) => { + if (!flux || flux <= 0) return { letter: '?', color: '#666', level: 0 }; + if (flux >= 1e-4) return { letter: 'X', color: '#ff0000', level: 4 }; + if (flux >= 1e-5) return { letter: 'M', color: '#ff6600', level: 3 }; + if (flux >= 1e-6) return { letter: 'C', color: '#ffcc00', level: 2 }; + if (flux >= 1e-7) return { letter: 'B', color: '#00cc88', level: 1 }; + return { letter: 'A', color: '#4488ff', level: 0 }; +}; + +// Format flux value for display +const formatFlux = (flux) => { + if (!flux || flux <= 0) return '--'; + const cls = getFlareClass(flux); + const base = flux >= 1e-4 ? flux / 1e-4 : + flux >= 1e-5 ? flux / 1e-5 : + flux >= 1e-6 ? flux / 1e-6 : + flux >= 1e-7 ? flux / 1e-7 : flux / 1e-8; + return `${cls.letter}${base.toFixed(1)}`; +}; export const SolarPanel = ({ solarIndices }) => { - const [showIndices, setShowIndices] = useState(() => { + const [mode, setMode] = useState(() => { try { const saved = localStorage.getItem('openhamclock_solarPanelMode'); - return saved === 'indices'; - } catch (e) { return false; } + if (MODES.includes(saved)) return saved; + // Migrate old boolean format + if (saved === 'indices') return 'indices'; + return 'image'; + } catch (e) { return 'image'; } }); const [imageType, setImageType] = useState('0193'); + const [xrayData, setXrayData] = useState(null); + const [xrayLoading, setXrayLoading] = useState(false); - const toggleMode = () => { - const newMode = !showIndices; - setShowIndices(newMode); - try { - localStorage.setItem('openhamclock_solarPanelMode', newMode ? 'indices' : 'image'); - } catch (e) {} + const cycleMode = () => { + const nextIdx = (MODES.indexOf(mode) + 1) % MODES.length; + const next = MODES[nextIdx]; + setMode(next); + try { localStorage.setItem('openhamclock_solarPanelMode', next); } catch (e) {} }; + + // Fetch X-ray data when xray mode is active + const fetchXray = useCallback(async () => { + try { + setXrayLoading(true); + const res = await fetch('/api/noaa/xray'); + if (res.ok) { + const data = await res.json(); + // Filter to 0.1-0.8nm (long wavelength, standard for flare classification) + const filtered = data.filter(d => d.energy === '0.1-0.8nm' && d.flux > 0); + setXrayData(filtered); + } + } catch (err) { + console.error('X-ray fetch error:', err); + } finally { + setXrayLoading(false); + } + }, []); + + useEffect(() => { + if (mode === 'xray') { + fetchXray(); + const interval = setInterval(fetchXray, 5 * 60 * 1000); // 5 min refresh + return () => clearInterval(interval); + } + }, [mode, fetchXray]); const imageTypes = { '0193': { name: 'AIA 193Å', desc: 'Corona' }, @@ -40,12 +96,158 @@ export const SolarPanel = ({ solarIndices }) => { return '#00ff88'; }; - // Get K-Index data - server returns 'kp' not 'kIndex' const kpData = solarIndices?.data?.kp || solarIndices?.data?.kIndex; + // X-Ray flux chart renderer + const renderXrayChart = () => { + if (xrayLoading && !xrayData) { + return
Loading X-ray data...
; + } + if (!xrayData || xrayData.length === 0) { + return
No X-ray data available
; + } + + // Use last ~360 points (~6 hours at 1-min resolution) + const points = xrayData.slice(-360); + const currentFlux = points[points.length - 1]?.flux; + const currentClass = getFlareClass(currentFlux); + const peakFlux = Math.max(...points.map(p => p.flux)); + const peakClass = getFlareClass(peakFlux); + + // Chart dimensions + const W = 280, H = 130; + const padL = 28, padR = 6, padT = 8, padB = 18; + const chartW = W - padL - padR; + const chartH = H - padT - padB; + + // Log scale: 1e-8 (A1.0) to 1e-3 (X10) + const logMin = -8, logMax = -3; + const logRange = logMax - logMin; + + const fluxToY = (flux) => { + if (!flux || flux <= 0) return padT + chartH; + const log = Math.log10(flux); + const clamped = Math.max(logMin, Math.min(logMax, log)); + return padT + chartH - ((clamped - logMin) / logRange) * chartH; + }; + + // Build SVG path + const pathD = points.map((p, i) => { + const x = padL + (i / (points.length - 1)) * chartW; + const y = fluxToY(p.flux); + return `${i === 0 ? 'M' : 'L'}${x.toFixed(1)},${y.toFixed(1)}`; + }).join(' '); + + // Gradient fill path + const fillD = pathD + ` L${(padL + chartW).toFixed(1)},${(padT + chartH).toFixed(1)} L${padL},${(padT + chartH).toFixed(1)} Z`; + + // Flare class threshold lines + const thresholds = [ + { flux: 1e-7, label: 'B', color: '#00cc88' }, + { flux: 1e-6, label: 'C', color: '#ffcc00' }, + { flux: 1e-5, label: 'M', color: '#ff6600' }, + { flux: 1e-4, label: 'X', color: '#ff0000' } + ]; + + // Time labels + const firstTime = new Date(points[0]?.time_tag); + const lastTime = new Date(points[points.length - 1]?.time_tag); + const midTime = new Date((firstTime.getTime() + lastTime.getTime()) / 2); + const fmt = (d) => `${String(d.getUTCHours()).padStart(2, '0')}:${String(d.getUTCMinutes()).padStart(2, '0')}`; + + return ( +
+ {/* Current level display */} +
+
+ Current + + {formatFlux(currentFlux)} + +
+
+ 6h Peak + + {formatFlux(peakFlux)} + +
+
+ + {/* SVG Chart */} + + + {/* Flare class background bands */} + {thresholds.map((t, i) => { + const y1 = fluxToY(t.flux); + const y0 = i === 0 ? padT + chartH : fluxToY(thresholds[i - 1].flux); + return ( + + ); + })} + {/* X class band to top */} + + + {/* Threshold lines */} + {thresholds.map(t => { + const y = fluxToY(t.flux); + return ( + + + {t.label} + + ); + })} + + {/* Gradient fill under curve */} + + + + + + + + + {/* Flux line */} + + + {/* Time axis labels */} + {fmt(firstTime)} + {fmt(midTime)} + {fmt(lastTime)} UTC + + +
+ GOES • 0.1–0.8nm • 6hr +
+
+ ); + }; + return (
- {/* Header with toggle */} + {/* Header with cycle button */}
{ marginBottom: '6px' }}> - ☀ {showIndices ? 'SOLAR INDICES' : 'SOLAR'} + ☀ {MODE_LABELS[mode]}
- {!showIndices && ( + {mode === 'image' && ( )}
- {showIndices ? ( + {mode === 'indices' ? ( /* Solar Indices View */
{solarIndices?.data ? ( @@ -208,6 +410,9 @@ export const SolarPanel = ({ solarIndices }) => {
)}
+ ) : mode === 'xray' ? ( + /* X-Ray Flux Chart View */ + renderXrayChart() ) : ( /* Solar Image View */
From dad591e26578903c380ee1b45d0075d54b967c76 Mon Sep 17 00:00:00 2001 From: accius Date: Mon, 2 Feb 2026 20:02:17 -0500 Subject: [PATCH 3/4] aurora overlay --- src/plugins/layerRegistry.js | 3 +- src/plugins/layers/useAurora.js | 129 ++++++++++++++++++++++++++++++++ 2 files changed, 131 insertions(+), 1 deletion(-) create mode 100644 src/plugins/layers/useAurora.js diff --git a/src/plugins/layerRegistry.js b/src/plugins/layerRegistry.js index ccaace2..013e816 100644 --- a/src/plugins/layerRegistry.js +++ b/src/plugins/layerRegistry.js @@ -1,14 +1,15 @@ /** * Layer Plugin Registry - * Only Weather Radar for now */ import * as WXRadarPlugin from './layers/useWXRadar.js'; import * as EarthquakesPlugin from './layers/useEarthquakes.js'; +import * as AuroraPlugin from './layers/useAurora.js'; const layerPlugins = [ WXRadarPlugin, EarthquakesPlugin, + AuroraPlugin, ]; export function getAllLayers() { diff --git a/src/plugins/layers/useAurora.js b/src/plugins/layers/useAurora.js new file mode 100644 index 0000000..e3eaa18 --- /dev/null +++ b/src/plugins/layers/useAurora.js @@ -0,0 +1,129 @@ +import { useState, useEffect } 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 + +export const metadata = { + id: 'aurora', + name: 'Aurora Forecast', + description: 'NOAA OVATION auroral oval forecast (Northern & Southern)', + icon: '🌌', + category: 'space-weather', + defaultEnabled: false, + defaultOpacity: 0.5, + version: '1.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()); + + // 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 + + useEffect(() => { + if (!map || typeof L === 'undefined') return; + + if (enabled) { + 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' + } + ); + + // 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); + } + if (southLayer) { + try { map.removeLayer(southLayer); } catch (e) {} + setSouthLayer(null); + } + } + + return () => { + if (northLayer && map) { + try { map.removeLayer(northLayer); } catch (e) {} + } + if (southLayer && map) { + try { map.removeLayer(southLayer); } catch (e) {} + } + }; + }, [enabled, map, refreshTimestamp]); + + // 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]); + + return { + layers: [northLayer, southLayer].filter(Boolean), + 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()); + } + }; +} From ec0ec3af80a7e31be506baa0782b9aa955c02778 Mon Sep 17 00:00:00 2001 From: accius Date: Mon, 2 Feb 2026 20:11:33 -0500 Subject: [PATCH 4/4] 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; } }; }