/**
* 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 */}
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 */}
{/* Phase info */}
{phaseName}
{illumination}% illuminated
{/* Next phases */}
);
};
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 && (
)}
{/* 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 && (
)}
) : (
Loading solar data...
)}
) : mode === 'xray' ? (
/* X-Ray Flux Chart View */
renderXrayChart()
) : mode === 'lunar' ? (
/* Lunar Phase View */
renderLunar()
) : (
/* Solar Image View */

{
e.target.style.display = 'none';
}}
/>
SDO/AIA • Live from NASA
)}
);
};
export default SolarPanel;