/** * SolarPanel Component * Cycles between: Solar Image → Solar Indices → X-Ray Flux Chart → Lunar Phase */ import React, { useState, useEffect, useCallback, useMemo } from 'react'; import { getMoonPhase } from '../utils/geo.js'; const MODES = ['image', 'indices', 'xray', 'lunar']; const MODE_LABELS = { image: 'SOLAR', indices: 'SOLAR INDICES', xray: 'X-RAY FLUX', lunar: 'LUNAR' }; const MODE_ICONS = { image: '◫', indices: '⊞', xray: '☽', lunar: '☼' }; const MODE_TITLES = { image: 'Show solar indices', indices: 'Show X-ray flux', xray: 'Show lunar phase', lunar: '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 [mode, setMode] = useState(() => { try { const saved = localStorage.getItem('openhamclock_solarPanelMode'); 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(() => { try { return localStorage.getItem('openhamclock_solarImageType') || '0193'; } catch { return '0193'; } }); const [xrayData, setXrayData] = useState(null); const [xrayLoading, setXrayLoading] = useState(false); 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' }, '0304': { name: 'AIA 304Å', desc: 'Chromosphere' }, '0171': { name: 'AIA 171Å', desc: 'Quiet Corona' }, '0094': { name: 'AIA 94Å', desc: 'Flaring' }, 'HMIIC': { name: 'HMI Int', desc: 'Visible' } }; const timestamp = Math.floor(Date.now() / 900000) * 900000; const imageUrl = `https://sdo.gsfc.nasa.gov/assets/img/latest/latest_256_${imageType}.jpg?t=${timestamp}`; const getKpColor = (value) => { if (value >= 7) return '#ff0000'; if (value >= 5) return '#ff6600'; if (value >= 4) return '#ffcc00'; if (value >= 3) return '#88cc00'; return '#00ff88'; }; 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
); }; // Lunar phase renderer const renderLunar = () => { const now = new Date(); const phase = getMoonPhase(now); // 0-1, 0=new, 0.5=full const illumination = Math.round((1 - Math.cos(phase * 2 * Math.PI)) / 2 * 100); // Phase name let phaseName = 'New Moon'; if (phase >= 0.0625 && phase < 0.1875) phaseName = 'Waxing Crescent'; else if (phase >= 0.1875 && phase < 0.3125) phaseName = 'First Quarter'; else if (phase >= 0.3125 && phase < 0.4375) phaseName = 'Waxing Gibbous'; else if (phase >= 0.4375 && phase < 0.5625) phaseName = 'Full Moon'; else if (phase >= 0.5625 && phase < 0.6875) phaseName = 'Waning Gibbous'; else if (phase >= 0.6875 && phase < 0.8125) phaseName = 'Last Quarter'; else if (phase >= 0.8125 && phase < 0.9375) phaseName = 'Waning Crescent'; // Find next full moon & new moon by scanning forward const findNextPhase = (targetPhase, label) => { const d = new Date(now); for (let i = 1; i <= 35; i++) { d.setDate(d.getDate() + 1); const p = getMoonPhase(d); const diff = Math.abs(p - targetPhase); if (diff < 0.018 || diff > 0.982) { return d.toLocaleDateString(undefined, { month: 'short', day: 'numeric' }); } } return '—'; }; const nextFull = findNextPhase(0.5, 'Full'); const nextNew = findNextPhase(0.0, 'New'); // SVG moon — uses a crescent/gibbous mask technique // phase 0=new(dark), 0.25=first quarter(right lit), 0.5=full(all lit), 0.75=last quarter(left lit) const R = 60; // moon radius const CX = 70, CY = 70; // The terminator curve is an ellipse whose x-radius varies with phase // At new moon (0): fully dark. At full (0.5): fully lit. // phase 0-0.5: right side lit (waxing), 0.5-1: left side lit (waning) const angle = phase * 2 * Math.PI; const terminatorX = R * Math.cos(angle); // ranges from R (new) through 0 (quarter) to -R (full) and back // Build the lit area path // Right half arc (from top to bottom) is always an arc of radius R // Left boundary (terminator) is an ellipse with rx = |terminatorX| const buildMoonPath = () => { // Lit portion: we draw two arcs — the outer limb and the terminator // For waxing (0 < phase < 0.5): right side is lit // For waning (0.5 < phase < 1): left side is lit if (phase < 0.01 || phase > 0.99) { // New moon — no lit area return null; } if (phase > 0.49 && phase < 0.51) { // Full moon — entire circle lit return `M${CX},${CY - R} A${R},${R} 0 1,1 ${CX},${CY + R} A${R},${R} 0 1,1 ${CX},${CY - R}`; } const absTermX = Math.abs(terminatorX); if (phase < 0.5) { // Waxing — right side lit // Outer arc: top to bottom along right limb (sweep=1, clockwise) // Terminator: bottom to top (elliptical arc) const sweepTerminator = phase < 0.25 ? 1 : 0; // concave before quarter, convex after return `M${CX},${CY - R} A${R},${R} 0 0,1 ${CX},${CY + R} A${absTermX},${R} 0 0,${sweepTerminator} ${CX},${CY - R}`; } else { // Waning — left side lit // Outer arc: top to bottom along left limb (sweep=0, counter-clockwise) // Terminator: bottom to top const sweepTerminator = phase > 0.75 ? 1 : 0; return `M${CX},${CY - R} A${R},${R} 0 0,0 ${CX},${CY + R} A${absTermX},${R} 0 0,${sweepTerminator} ${CX},${CY - R}`; } }; const litPath = buildMoonPath(); return (
{/* Moon SVG */}
{/* Crater texture */} {/* Clip to circle */} {/* Dark side (always full circle, dark) */} {/* Lit surface with craters — clipped to lit path */} {litPath && ( {/* Mare (dark patches) */} {/* Craters */} )} {/* Subtle glow */}
{/* Phase info */}
{phaseName}
{illumination}% illuminated
{/* Next phases */}
● New
{nextNew}
○ Full
{nextFull}
); }; return (
{/* Header with cycle button */}
{mode === 'lunar' ? '🌙' : '☀'} {MODE_LABELS[mode]}
{mode === 'image' && ( )}
{mode === 'indices' ? ( /* Solar Indices View */
{solarIndices?.data ? (
{/* SFI Row */}
SFI
{solarIndices.data.sfi?.current || '--'}
{solarIndices.data.sfi?.history?.length > 0 && ( {(() => { const data = solarIndices.data.sfi.history.slice(-20); const values = data.map(d => d.value); const max = Math.max(...values, 1); const min = Math.min(...values); const range = max - min || 1; const points = data.map((d, i) => { const x = (i / (data.length - 1)) * 100; const y = 30 - ((d.value - min) / range) * 25; return `${x},${y}`; }).join(' '); return ; })()} )}
{/* K-Index Row */}
K-Index
{kpData?.current ?? '--'}
{kpData?.forecast?.length > 0 ? (
{kpData.forecast.slice(0, 8).map((item, i) => { const val = typeof item === 'object' ? item.value : item; return (
); })}
) : kpData?.history?.length > 0 ? (
{kpData.history.slice(-8).map((item, i) => { const val = typeof item === 'object' ? item.value : item; return (
); })}
) : (
No forecast data
)}
{/* SSN Row */}
SSN
{solarIndices.data.ssn?.current || '--'}
{solarIndices.data.ssn?.history?.length > 0 && ( {(() => { const data = solarIndices.data.ssn.history.slice(-20); const values = data.map(d => d.value); const max = Math.max(...values, 1); const min = Math.min(...values, 0); const range = max - min || 1; const points = data.map((d, i) => { const x = (i / (data.length - 1)) * 100; const y = 30 - ((d.value - min) / range) * 25; return `${x},${y}`; }).join(' '); return ; })()} )}
) : (
Loading solar data...
)}
) : mode === 'xray' ? ( /* X-Ray Flux Chart View */ renderXrayChart() ) : mode === 'lunar' ? ( /* Lunar Phase View */ renderLunar() ) : ( /* Solar Image View */
SDO Solar Image { e.target.style.display = 'none'; }} />
SDO/AIA • Live from NASA
)}
); }; export default SolarPanel;