xray module

pull/45/head
accius 2 days ago
parent 1b22763e47
commit b28727f131

@ -670,11 +670,16 @@ app.get('/api/dxpeditions', async (req, res) => {
// NOAA Space Weather - X-Ray Flux // NOAA Space Weather - X-Ray Flux
app.get('/api/noaa/xray', async (req, res) => { app.get('/api/noaa/xray', async (req, res) => {
try { 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(); const data = await response.json();
noaaCache.xray = { data, timestamp: Date.now() };
res.json(data); res.json(data);
} catch (error) { } catch (error) {
console.error('NOAA X-Ray API error:', error.message); 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' }); res.status(500).json({ error: 'Failed to fetch X-ray data' });
} }
}); });

@ -1,26 +1,82 @@
/** /**
* SolarPanel Component * 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 }) => { export const SolarPanel = ({ solarIndices }) => {
const [showIndices, setShowIndices] = useState(() => { const [mode, setMode] = useState(() => {
try { try {
const saved = localStorage.getItem('openhamclock_solarPanelMode'); const saved = localStorage.getItem('openhamclock_solarPanelMode');
return saved === 'indices'; if (MODES.includes(saved)) return saved;
} catch (e) { return false; } // Migrate old boolean format
if (saved === 'indices') return 'indices';
return 'image';
} catch (e) { return 'image'; }
}); });
const [imageType, setImageType] = useState('0193'); const [imageType, setImageType] = useState('0193');
const [xrayData, setXrayData] = useState(null);
const [xrayLoading, setXrayLoading] = useState(false);
const toggleMode = () => { const cycleMode = () => {
const newMode = !showIndices; const nextIdx = (MODES.indexOf(mode) + 1) % MODES.length;
setShowIndices(newMode); const next = MODES[nextIdx];
try { setMode(next);
localStorage.setItem('openhamclock_solarPanelMode', newMode ? 'indices' : 'image'); try { localStorage.setItem('openhamclock_solarPanelMode', next); } catch (e) {}
} 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 = { const imageTypes = {
'0193': { name: 'AIA 193Å', desc: 'Corona' }, '0193': { name: 'AIA 193Å', desc: 'Corona' },
'0304': { name: 'AIA 304Å', desc: 'Chromosphere' }, '0304': { name: 'AIA 304Å', desc: 'Chromosphere' },
@ -40,12 +96,158 @@ export const SolarPanel = ({ solarIndices }) => {
return '#00ff88'; return '#00ff88';
}; };
// Get K-Index data - server returns 'kp' not 'kIndex'
const kpData = solarIndices?.data?.kp || solarIndices?.data?.kIndex; const kpData = solarIndices?.data?.kp || solarIndices?.data?.kIndex;
// X-Ray flux chart renderer
const renderXrayChart = () => {
if (xrayLoading && !xrayData) {
return <div style={{ textAlign: 'center', color: 'var(--text-muted)', padding: '20px' }}>Loading X-ray data...</div>;
}
if (!xrayData || xrayData.length === 0) {
return <div style={{ textAlign: 'center', color: 'var(--text-muted)', padding: '20px' }}>No X-ray data available</div>;
}
// 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 (
<div>
{/* Current level display */}
<div style={{
display: 'flex',
justifyContent: 'space-between',
alignItems: 'center',
marginBottom: '6px',
padding: '4px 8px',
background: 'var(--bg-tertiary)',
borderRadius: '6px'
}}>
<div>
<span style={{ fontSize: '10px', color: 'var(--text-muted)' }}>Current </span>
<span style={{
fontSize: '18px', fontWeight: '700', color: currentClass.color,
fontFamily: 'Orbitron, monospace'
}}>
{formatFlux(currentFlux)}
</span>
</div>
<div>
<span style={{ fontSize: '10px', color: 'var(--text-muted)' }}>6h Peak </span>
<span style={{
fontSize: '14px', fontWeight: '600', color: peakClass.color,
fontFamily: 'Orbitron, monospace'
}}>
{formatFlux(peakFlux)}
</span>
</div>
</div>
{/* SVG Chart */}
<svg width="100%" viewBox={`0 0 ${W} ${H}`} preserveAspectRatio="xMidYMid meet"
style={{ background: 'var(--bg-tertiary)', borderRadius: '6px' }}>
{/* 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 (
<rect key={t.label} x={padL} y={y1} width={chartW} height={y0 - y1}
fill={t.color} opacity={0.06} />
);
})}
{/* X class band to top */}
<rect x={padL} y={padT} width={chartW}
height={fluxToY(1e-4) - padT} fill="#ff0000" opacity={0.06} />
{/* Threshold lines */}
{thresholds.map(t => {
const y = fluxToY(t.flux);
return (
<g key={t.label}>
<line x1={padL} y1={y} x2={padL + chartW} y2={y}
stroke={t.color} strokeWidth="0.5" strokeDasharray="3,3" opacity={0.5} />
<text x={padL + 2} y={y - 2} fill={t.color} fontSize="8" fontWeight="600"
fontFamily="JetBrains Mono, monospace">{t.label}</text>
</g>
);
})}
{/* Gradient fill under curve */}
<defs>
<linearGradient id="xrayGrad" x1="0" y1="0" x2="0" y2="1">
<stop offset="0%" stopColor={currentClass.color} stopOpacity="0.4" />
<stop offset="100%" stopColor={currentClass.color} stopOpacity="0.05" />
</linearGradient>
</defs>
<path d={fillD} fill="url(#xrayGrad)" />
{/* Flux line */}
<path d={pathD} fill="none" stroke={currentClass.color} strokeWidth="1.5" />
{/* Time axis labels */}
<text x={padL} y={H - 2} fill="var(--text-muted, #888)" fontSize="8"
fontFamily="JetBrains Mono, monospace">{fmt(firstTime)}</text>
<text x={padL + chartW / 2} y={H - 2} fill="var(--text-muted, #888)" fontSize="8"
fontFamily="JetBrains Mono, monospace" textAnchor="middle">{fmt(midTime)}</text>
<text x={padL + chartW} y={H - 2} fill="var(--text-muted, #888)" fontSize="8"
fontFamily="JetBrains Mono, monospace" textAnchor="end">{fmt(lastTime)} UTC</text>
</svg>
<div style={{ fontSize: '9px', color: 'var(--text-muted)', marginTop: '3px', textAlign: 'center' }}>
GOES 0.10.8nm 6hr
</div>
</div>
);
};
return ( return (
<div className="panel" style={{ padding: '8px' }}> <div className="panel" style={{ padding: '8px' }}>
{/* Header with toggle */} {/* Header with cycle button */}
<div style={{ <div style={{
display: 'flex', display: 'flex',
justifyContent: 'space-between', justifyContent: 'space-between',
@ -53,10 +255,10 @@ export const SolarPanel = ({ solarIndices }) => {
marginBottom: '6px' marginBottom: '6px'
}}> }}>
<span style={{ fontSize: '12px', color: 'var(--accent-amber)', fontWeight: '700' }}> <span style={{ fontSize: '12px', color: 'var(--accent-amber)', fontWeight: '700' }}>
{showIndices ? 'SOLAR INDICES' : 'SOLAR'} {MODE_LABELS[mode]}
</span> </span>
<div style={{ display: 'flex', gap: '4px', alignItems: 'center' }}> <div style={{ display: 'flex', gap: '4px', alignItems: 'center' }}>
{!showIndices && ( {mode === 'image' && (
<select <select
value={imageType} value={imageType}
onChange={(e) => setImageType(e.target.value)} onChange={(e) => setImageType(e.target.value)}
@ -77,7 +279,7 @@ export const SolarPanel = ({ solarIndices }) => {
</select> </select>
)} )}
<button <button
onClick={toggleMode} onClick={cycleMode}
style={{ style={{
background: 'var(--bg-tertiary)', background: 'var(--bg-tertiary)',
border: '1px solid var(--border-color)', border: '1px solid var(--border-color)',
@ -87,14 +289,14 @@ export const SolarPanel = ({ solarIndices }) => {
borderRadius: '3px', borderRadius: '3px',
cursor: 'pointer' cursor: 'pointer'
}} }}
title={showIndices ? 'Show solar image' : 'Show solar indices'} title={MODE_TITLES[mode]}
> >
{showIndices ? '🖼️' : '📊'} {MODE_ICONS[mode]}
</button> </button>
</div> </div>
</div> </div>
{showIndices ? ( {mode === 'indices' ? (
/* Solar Indices View */ /* Solar Indices View */
<div> <div>
{solarIndices?.data ? ( {solarIndices?.data ? (
@ -208,6 +410,9 @@ export const SolarPanel = ({ solarIndices }) => {
</div> </div>
)} )}
</div> </div>
) : mode === 'xray' ? (
/* X-Ray Flux Chart View */
renderXrayChart()
) : ( ) : (
/* Solar Image View */ /* Solar Image View */
<div style={{ textAlign: 'center' }}> <div style={{ textAlign: 'center' }}>

Loading…
Cancel
Save

Powered by TurnKey Linux.