parent
e5bbeff069
commit
6adc7e9fdf
@ -0,0 +1,85 @@
|
||||
/**
|
||||
* DXpeditionPanel Component
|
||||
* Shows active and upcoming DXpeditions
|
||||
*/
|
||||
import React from 'react';
|
||||
|
||||
export const DXpeditionPanel = ({ data, loading }) => {
|
||||
const getStatusStyle = (expedition) => {
|
||||
if (expedition.isActive) {
|
||||
return { bg: 'rgba(0, 255, 136, 0.15)', border: 'var(--accent-green)', color: 'var(--accent-green)' };
|
||||
}
|
||||
if (expedition.isUpcoming) {
|
||||
return { bg: 'rgba(0, 170, 255, 0.15)', border: 'var(--accent-cyan)', color: 'var(--accent-cyan)' };
|
||||
}
|
||||
return { bg: 'var(--bg-tertiary)', border: 'var(--border-color)', color: 'var(--text-muted)' };
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="panel" style={{ padding: '12px' }}>
|
||||
<div className="panel-header" style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', marginBottom: '8px' }}>
|
||||
<span>🌍 DXPEDITIONS</span>
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: '8px' }}>
|
||||
{loading && <div className="loading-spinner" />}
|
||||
{data && (
|
||||
<span style={{ fontSize: '10px', color: 'var(--text-muted)' }}>
|
||||
{data.active > 0 && <span style={{ color: 'var(--accent-green)' }}>{data.active} active</span>}
|
||||
{data.active > 0 && data.upcoming > 0 && ' • '}
|
||||
{data.upcoming > 0 && <span style={{ color: 'var(--accent-cyan)' }}>{data.upcoming} upcoming</span>}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div style={{ maxHeight: '200px', overflowY: 'auto' }}>
|
||||
{data?.dxpeditions?.length > 0 ? (
|
||||
data.dxpeditions.slice(0, 15).map((exp, idx) => {
|
||||
const style = getStatusStyle(exp);
|
||||
return (
|
||||
<div key={idx} style={{
|
||||
padding: '6px 8px',
|
||||
marginBottom: '4px',
|
||||
background: style.bg,
|
||||
borderLeft: `3px solid ${style.border}`,
|
||||
borderRadius: '4px',
|
||||
fontSize: '12px',
|
||||
fontFamily: 'JetBrains Mono, monospace'
|
||||
}}>
|
||||
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center' }}>
|
||||
<span style={{ color: 'var(--accent-amber)', fontWeight: '700', fontSize: '13px' }}>{exp.callsign}</span>
|
||||
<span style={{ color: style.color, fontSize: '10px' }}>
|
||||
{exp.isActive ? '● NOW' : exp.isUpcoming ? 'UPCOMING' : 'PAST'}
|
||||
</span>
|
||||
</div>
|
||||
<div style={{ color: 'var(--text-secondary)', marginTop: '2px' }}>
|
||||
{exp.entity}
|
||||
</div>
|
||||
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', marginTop: '3px' }}>
|
||||
<span style={{ color: 'var(--text-muted)', fontSize: '11px' }}>{exp.dates}</span>
|
||||
<div style={{ display: 'flex', gap: '6px', fontSize: '10px' }}>
|
||||
{exp.bands && <span style={{ color: 'var(--accent-purple)' }}>{exp.bands.split(' ').slice(0, 3).join(' ')}</span>}
|
||||
{exp.modes && <span style={{ color: 'var(--accent-cyan)' }}>{exp.modes.split(' ').slice(0, 2).join(' ')}</span>}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})
|
||||
) : (
|
||||
<div style={{ textAlign: 'center', color: 'var(--text-muted)', padding: '20px' }}>
|
||||
{loading ? 'Loading DXpeditions...' : 'No DXpedition data available'}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{data && (
|
||||
<div style={{ marginTop: '6px', textAlign: 'right', fontSize: '9px' }}>
|
||||
<a href="https://www.ng3k.com/misc/adxo.html" target="_blank" rel="noopener noreferrer" style={{ color: 'var(--text-muted)', textDecoration: 'none' }}>
|
||||
NG3K ADXO Calendar
|
||||
</a>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default DXpeditionPanel;
|
||||
@ -0,0 +1,312 @@
|
||||
/**
|
||||
* PropagationPanel Component (VOACAP)
|
||||
* Toggleable between heatmap chart, bar chart, and band conditions view
|
||||
*/
|
||||
import React, { useState } from 'react';
|
||||
|
||||
export const PropagationPanel = ({ propagation, loading, bandConditions }) => {
|
||||
// Load view mode preference from localStorage
|
||||
const [viewMode, setViewMode] = useState(() => {
|
||||
try {
|
||||
const saved = localStorage.getItem('openhamclock_voacapViewMode');
|
||||
if (saved === 'bars' || saved === 'bands') return saved;
|
||||
return 'chart';
|
||||
} catch (e) { return 'chart'; }
|
||||
});
|
||||
|
||||
// Cycle through view modes
|
||||
const cycleViewMode = () => {
|
||||
const modes = ['chart', 'bars', 'bands'];
|
||||
const currentIdx = modes.indexOf(viewMode);
|
||||
const newMode = modes[(currentIdx + 1) % modes.length];
|
||||
setViewMode(newMode);
|
||||
try {
|
||||
localStorage.setItem('openhamclock_voacapViewMode', newMode);
|
||||
} catch (e) {}
|
||||
};
|
||||
|
||||
const getBandStyle = (condition) => ({
|
||||
GOOD: { bg: 'rgba(0,255,136,0.2)', color: '#00ff88', border: 'rgba(0,255,136,0.4)' },
|
||||
FAIR: { bg: 'rgba(255,180,50,0.2)', color: '#ffb432', border: 'rgba(255,180,50,0.4)' },
|
||||
POOR: { bg: 'rgba(255,68,102,0.2)', color: '#ff4466', border: 'rgba(255,68,102,0.4)' }
|
||||
}[condition] || { bg: 'rgba(255,180,50,0.2)', color: '#ffb432', border: 'rgba(255,180,50,0.4)' });
|
||||
|
||||
if (loading || !propagation) {
|
||||
return (
|
||||
<div className="panel">
|
||||
<div className="panel-header">📡 VOACAP</div>
|
||||
<div style={{ padding: '20px', textAlign: 'center', color: 'var(--text-muted)' }}>
|
||||
Loading predictions...
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
const { solarData, distance, currentBands, currentHour, hourlyPredictions, muf, luf, ionospheric, dataSource } = propagation;
|
||||
const hasRealData = ionospheric?.method === 'direct' || ionospheric?.method === 'interpolated';
|
||||
|
||||
// Heat map colors (VOACAP style - red=good, green=poor)
|
||||
const getHeatColor = (rel) => {
|
||||
if (rel >= 80) return '#ff0000';
|
||||
if (rel >= 60) return '#ff6600';
|
||||
if (rel >= 40) return '#ffcc00';
|
||||
if (rel >= 20) return '#88cc00';
|
||||
if (rel >= 10) return '#00aa00';
|
||||
return '#004400';
|
||||
};
|
||||
|
||||
const getReliabilityColor = (rel) => {
|
||||
if (rel >= 70) return '#00ff88';
|
||||
if (rel >= 50) return '#88ff00';
|
||||
if (rel >= 30) return '#ffcc00';
|
||||
if (rel >= 15) return '#ff8800';
|
||||
return '#ff4444';
|
||||
};
|
||||
|
||||
const getStatusColor = (status) => {
|
||||
switch (status) {
|
||||
case 'EXCELLENT': return '#00ff88';
|
||||
case 'GOOD': return '#88ff00';
|
||||
case 'FAIR': return '#ffcc00';
|
||||
case 'POOR': return '#ff8800';
|
||||
case 'CLOSED': return '#ff4444';
|
||||
default: return 'var(--text-muted)';
|
||||
}
|
||||
};
|
||||
|
||||
const bands = ['80m', '40m', '30m', '20m', '17m', '15m', '12m', '11m', '10m'];
|
||||
const viewModeLabels = { chart: '▤ chart', bars: '▦ bars', bands: '◫ bands' };
|
||||
|
||||
return (
|
||||
<div className="panel" style={{ cursor: 'pointer' }} onClick={cycleViewMode}>
|
||||
<div className="panel-header" style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center' }}>
|
||||
<span>
|
||||
{viewMode === 'bands' ? '📊 BAND CONDITIONS' : '📡 VOACAP'}
|
||||
{hasRealData && viewMode !== 'bands' && <span style={{ color: '#00ff88', fontSize: '10px', marginLeft: '4px' }}>●</span>}
|
||||
</span>
|
||||
<span style={{ fontSize: '10px', color: 'var(--text-muted)' }}>
|
||||
{viewModeLabels[viewMode]} • click to toggle
|
||||
</span>
|
||||
</div>
|
||||
|
||||
{viewMode === 'bands' ? (
|
||||
/* Band Conditions Grid View */
|
||||
<div style={{ padding: '4px' }}>
|
||||
<div style={{ display: 'grid', gridTemplateColumns: 'repeat(4, 1fr)', gap: '4px' }}>
|
||||
{(bandConditions?.data || []).slice(0, 13).map((band, idx) => {
|
||||
const style = getBandStyle(band.condition);
|
||||
return (
|
||||
<div key={idx} style={{
|
||||
background: style.bg,
|
||||
border: `1px solid ${style.border}`,
|
||||
borderRadius: '4px',
|
||||
padding: '6px 2px',
|
||||
textAlign: 'center'
|
||||
}}>
|
||||
<div style={{ fontFamily: 'Orbitron, monospace', fontSize: '13px', fontWeight: '700', color: style.color }}>
|
||||
{band.band}
|
||||
</div>
|
||||
<div style={{ fontSize: '9px', fontWeight: '600', color: style.color, marginTop: '2px', opacity: 0.8 }}>
|
||||
{band.condition}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
<div style={{ marginTop: '6px', fontSize: '10px', color: 'var(--text-muted)', textAlign: 'center' }}>
|
||||
SFI {solarData?.sfi} • K {solarData?.kIndex} • General conditions for all paths
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<>
|
||||
{/* MUF/LUF and Data Source Info */}
|
||||
<div style={{
|
||||
display: 'flex',
|
||||
justifyContent: 'space-between',
|
||||
padding: '4px 8px',
|
||||
background: hasRealData ? 'rgba(0, 255, 136, 0.1)' : 'var(--bg-tertiary)',
|
||||
borderRadius: '4px',
|
||||
marginBottom: '4px',
|
||||
fontSize: '11px'
|
||||
}}>
|
||||
<div style={{ display: 'flex', gap: '12px' }}>
|
||||
<span>
|
||||
<span style={{ color: 'var(--text-muted)' }}>MUF </span>
|
||||
<span style={{ color: '#ff8800', fontWeight: '600' }}>{muf || '?'}</span>
|
||||
<span style={{ color: 'var(--text-muted)' }}> MHz</span>
|
||||
</span>
|
||||
<span>
|
||||
<span style={{ color: 'var(--text-muted)' }}>LUF </span>
|
||||
<span style={{ color: '#00aaff', fontWeight: '600' }}>{luf || '?'}</span>
|
||||
<span style={{ color: 'var(--text-muted)' }}> MHz</span>
|
||||
</span>
|
||||
</div>
|
||||
<span style={{ color: hasRealData ? '#00ff88' : 'var(--text-muted)', fontSize: '10px' }}>
|
||||
{hasRealData
|
||||
? `📡 ${ionospheric?.source || 'ionosonde'}${ionospheric?.distance ? ` (${ionospheric.distance}km)` : ''}`
|
||||
: '⚡ estimated'
|
||||
}
|
||||
</span>
|
||||
{dataSource && dataSource.includes('ITU') && (
|
||||
<span style={{
|
||||
color: '#ff6b35',
|
||||
fontSize: '9px',
|
||||
marginLeft: '8px',
|
||||
padding: '1px 4px',
|
||||
background: 'rgba(255,107,53,0.15)',
|
||||
borderRadius: '3px'
|
||||
}}>
|
||||
🔬 ITU-R P.533
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{viewMode === 'chart' ? (
|
||||
/* VOACAP Heat Map Chart View */
|
||||
<div style={{ padding: '4px' }}>
|
||||
<div style={{
|
||||
display: 'grid',
|
||||
gridTemplateColumns: '28px repeat(24, 1fr)',
|
||||
gridTemplateRows: `repeat(${bands.length}, 12px)`,
|
||||
gap: '1px',
|
||||
fontSize: '12px',
|
||||
fontFamily: 'JetBrains Mono, monospace'
|
||||
}}>
|
||||
{bands.map((band) => (
|
||||
<React.Fragment key={band}>
|
||||
<div style={{
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'flex-end',
|
||||
paddingRight: '4px',
|
||||
color: 'var(--text-muted)',
|
||||
fontSize: '12px'
|
||||
}}>
|
||||
{band.replace('m', '')}
|
||||
</div>
|
||||
{Array.from({ length: 24 }, (_, hour) => {
|
||||
let rel = 0;
|
||||
if (hour === currentHour && currentBands?.length > 0) {
|
||||
const currentBandData = currentBands.find(b => b.band === band);
|
||||
if (currentBandData) {
|
||||
rel = currentBandData.reliability || 0;
|
||||
}
|
||||
} else {
|
||||
const bandData = hourlyPredictions?.[band];
|
||||
const hourData = bandData?.find(h => h.hour === hour);
|
||||
rel = hourData?.reliability || 0;
|
||||
}
|
||||
return (
|
||||
<div
|
||||
key={hour}
|
||||
style={{
|
||||
background: getHeatColor(rel),
|
||||
borderRadius: '1px',
|
||||
border: hour === currentHour ? '1px solid white' : 'none'
|
||||
}}
|
||||
title={`${band} @ ${hour}:00 UTC: ${rel}%`}
|
||||
/>
|
||||
);
|
||||
})}
|
||||
</React.Fragment>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{/* Hour labels */}
|
||||
<div style={{
|
||||
display: 'grid',
|
||||
gridTemplateColumns: '28px repeat(24, 1fr)',
|
||||
marginTop: '2px',
|
||||
fontSize: '9px',
|
||||
color: 'var(--text-muted)'
|
||||
}}>
|
||||
<div>UTC</div>
|
||||
{[0, '', '', 3, '', '', 6, '', '', 9, '', '', 12, '', '', 15, '', '', 18, '', '', 21, '', ''].map((h, i) => (
|
||||
<div key={i} style={{ textAlign: 'center' }}>{h}</div>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{/* Legend */}
|
||||
<div style={{
|
||||
marginTop: '6px',
|
||||
display: 'flex',
|
||||
justifyContent: 'space-between',
|
||||
alignItems: 'center',
|
||||
fontSize: '11px'
|
||||
}}>
|
||||
<div style={{ display: 'flex', gap: '2px', alignItems: 'center' }}>
|
||||
<span style={{ color: 'var(--text-muted)' }}>REL:</span>
|
||||
{['#004400', '#00aa00', '#88cc00', '#ffcc00', '#ff6600', '#ff0000'].map((c, i) => (
|
||||
<div key={i} style={{ width: '8px', height: '8px', background: c, borderRadius: '1px' }} />
|
||||
))}
|
||||
</div>
|
||||
<div style={{ color: 'var(--text-muted)' }}>
|
||||
{Math.round(distance || 0)}km • {ionospheric?.foF2 ? `foF2=${ionospheric.foF2}` : `SSN=${solarData?.ssn}`}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
/* Bar Chart View */
|
||||
<div style={{ fontSize: '13px' }}>
|
||||
<div style={{
|
||||
display: 'flex',
|
||||
justifyContent: 'space-around',
|
||||
padding: '4px',
|
||||
marginBottom: '4px',
|
||||
background: 'var(--bg-tertiary)',
|
||||
borderRadius: '4px',
|
||||
fontSize: '11px'
|
||||
}}>
|
||||
<span><span style={{ color: 'var(--text-muted)' }}>SFI </span><span style={{ color: 'var(--accent-amber)' }}>{solarData?.sfi}</span></span>
|
||||
{ionospheric?.foF2 ? (
|
||||
<span><span style={{ color: 'var(--text-muted)' }}>foF2 </span><span style={{ color: '#00ff88' }}>{ionospheric.foF2}</span></span>
|
||||
) : (
|
||||
<span><span style={{ color: 'var(--text-muted)' }}>SSN </span><span style={{ color: 'var(--accent-cyan)' }}>{solarData?.ssn}</span></span>
|
||||
)}
|
||||
<span><span style={{ color: 'var(--text-muted)' }}>K </span><span style={{ color: solarData?.kIndex >= 4 ? '#ff4444' : '#00ff88' }}>{solarData?.kIndex}</span></span>
|
||||
</div>
|
||||
|
||||
{(currentBands || []).slice(0, 11).map((band) => (
|
||||
<div key={band.band} style={{
|
||||
display: 'grid',
|
||||
gridTemplateColumns: '32px 1fr 40px',
|
||||
gap: '4px',
|
||||
padding: '2px 0',
|
||||
alignItems: 'center'
|
||||
}}>
|
||||
<span style={{
|
||||
fontFamily: 'JetBrains Mono, monospace',
|
||||
fontSize: '12px',
|
||||
color: band.reliability >= 50 ? 'var(--accent-green)' : 'var(--text-muted)'
|
||||
}}>
|
||||
{band.band}
|
||||
</span>
|
||||
<div style={{ position: 'relative', height: '10px', background: 'var(--bg-tertiary)', borderRadius: '2px' }}>
|
||||
<div style={{
|
||||
position: 'absolute',
|
||||
top: 0,
|
||||
left: 0,
|
||||
height: '100%',
|
||||
width: `${band.reliability}%`,
|
||||
background: getReliabilityColor(band.reliability),
|
||||
borderRadius: '2px'
|
||||
}} />
|
||||
</div>
|
||||
<span style={{
|
||||
textAlign: 'right',
|
||||
fontSize: '12px',
|
||||
color: getStatusColor(band.status)
|
||||
}}>
|
||||
{band.reliability}%
|
||||
</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default PropagationPanel;
|
||||
@ -0,0 +1,215 @@
|
||||
/**
|
||||
* SolarPanel Component
|
||||
* Toggleable between live sun image from NASA SDO and solar indices display
|
||||
*/
|
||||
import React, { useState } from 'react';
|
||||
|
||||
export const SolarPanel = ({ solarIndices }) => {
|
||||
const [showIndices, setShowIndices] = useState(() => {
|
||||
try {
|
||||
const saved = localStorage.getItem('openhamclock_solarPanelMode');
|
||||
return saved === 'indices';
|
||||
} catch (e) { return false; }
|
||||
});
|
||||
const [imageType, setImageType] = useState('0193'); // AIA 193 (corona)
|
||||
|
||||
const toggleMode = () => {
|
||||
const newMode = !showIndices;
|
||||
setShowIndices(newMode);
|
||||
try {
|
||||
localStorage.setItem('openhamclock_solarPanelMode', newMode ? 'indices' : 'image');
|
||||
} catch (e) {}
|
||||
};
|
||||
|
||||
// SDO/AIA image types
|
||||
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' }
|
||||
};
|
||||
|
||||
// SDO images update every ~15 minutes
|
||||
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';
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="panel" style={{ padding: '8px' }}>
|
||||
{/* Header with toggle */}
|
||||
<div style={{
|
||||
display: 'flex',
|
||||
justifyContent: 'space-between',
|
||||
alignItems: 'center',
|
||||
marginBottom: '6px'
|
||||
}}>
|
||||
<span style={{ fontSize: '12px', color: 'var(--accent-amber)', fontWeight: '700' }}>
|
||||
☀ {showIndices ? 'SOLAR INDICES' : 'SOLAR'}
|
||||
</span>
|
||||
<div style={{ display: 'flex', gap: '4px', alignItems: 'center' }}>
|
||||
{!showIndices && (
|
||||
<select
|
||||
value={imageType}
|
||||
onChange={(e) => setImageType(e.target.value)}
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
style={{
|
||||
background: 'var(--bg-tertiary)',
|
||||
border: '1px solid var(--border-color)',
|
||||
color: 'var(--text-secondary)',
|
||||
fontSize: '10px',
|
||||
padding: '2px 4px',
|
||||
borderRadius: '3px',
|
||||
cursor: 'pointer'
|
||||
}}
|
||||
>
|
||||
{Object.entries(imageTypes).map(([key, val]) => (
|
||||
<option key={key} value={key}>{val.desc}</option>
|
||||
))}
|
||||
</select>
|
||||
)}
|
||||
<button
|
||||
onClick={toggleMode}
|
||||
style={{
|
||||
background: 'var(--bg-tertiary)',
|
||||
border: '1px solid var(--border-color)',
|
||||
color: 'var(--text-secondary)',
|
||||
fontSize: '10px',
|
||||
padding: '2px 6px',
|
||||
borderRadius: '3px',
|
||||
cursor: 'pointer'
|
||||
}}
|
||||
title={showIndices ? 'Show solar image' : 'Show solar indices'}
|
||||
>
|
||||
{showIndices ? '🖼️' : '📊'}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{showIndices ? (
|
||||
/* Solar Indices View */
|
||||
<div>
|
||||
{solarIndices?.data ? (
|
||||
<div style={{ display: 'flex', flexDirection: 'column', gap: '8px' }}>
|
||||
{/* SFI Row */}
|
||||
<div style={{ background: 'var(--bg-tertiary)', borderRadius: '6px', padding: '8px', display: 'flex', alignItems: 'center', gap: '12px' }}>
|
||||
<div style={{ minWidth: '60px' }}>
|
||||
<div style={{ fontSize: '10px', color: 'var(--text-muted)' }}>SFI</div>
|
||||
<div style={{ fontSize: '22px', fontWeight: '700', color: '#ff8800', fontFamily: 'Orbitron, monospace' }}>
|
||||
{solarIndices.data.sfi?.current || '--'}
|
||||
</div>
|
||||
</div>
|
||||
<div style={{ flex: 1 }}>
|
||||
{solarIndices.data.sfi?.history?.length > 0 && (
|
||||
<svg width="100%" height="30" viewBox="0 0 100 30" preserveAspectRatio="none">
|
||||
{(() => {
|
||||
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 <polyline points={points} fill="none" stroke="#ff8800" strokeWidth="1.5" />;
|
||||
})()}
|
||||
</svg>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* K-Index Row */}
|
||||
<div style={{ background: 'var(--bg-tertiary)', borderRadius: '6px', padding: '8px', display: 'flex', alignItems: 'center', gap: '12px' }}>
|
||||
<div style={{ minWidth: '60px' }}>
|
||||
<div style={{ fontSize: '10px', color: 'var(--text-muted)' }}>K-Index</div>
|
||||
<div style={{ fontSize: '22px', fontWeight: '700', color: getKpColor(solarIndices.data.kIndex?.current), fontFamily: 'Orbitron, monospace' }}>
|
||||
{solarIndices.data.kIndex?.current ?? '--'}
|
||||
</div>
|
||||
</div>
|
||||
<div style={{ flex: 1 }}>
|
||||
{solarIndices.data.kIndex?.forecast?.length > 0 && (
|
||||
<div style={{ display: 'flex', gap: '2px', alignItems: 'flex-end', height: '30px' }}>
|
||||
{solarIndices.data.kIndex.forecast.slice(0, 8).map((kp, i) => (
|
||||
<div key={i} style={{
|
||||
flex: 1,
|
||||
height: `${Math.max(10, (kp / 9) * 100)}%`,
|
||||
background: getKpColor(kp),
|
||||
borderRadius: '2px',
|
||||
opacity: 0.8
|
||||
}} title={`Kp ${kp}`} />
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* SSN Row */}
|
||||
<div style={{ background: 'var(--bg-tertiary)', borderRadius: '6px', padding: '8px', display: 'flex', alignItems: 'center', gap: '12px' }}>
|
||||
<div style={{ minWidth: '60px' }}>
|
||||
<div style={{ fontSize: '10px', color: 'var(--text-muted)' }}>SSN</div>
|
||||
<div style={{ fontSize: '22px', fontWeight: '700', color: '#aa88ff', fontFamily: 'Orbitron, monospace' }}>
|
||||
{solarIndices.data.ssn?.current || '--'}
|
||||
</div>
|
||||
</div>
|
||||
<div style={{ flex: 1 }}>
|
||||
{solarIndices.data.ssn?.history?.length > 0 && (
|
||||
<svg width="100%" height="30" viewBox="0 0 100 30" preserveAspectRatio="none">
|
||||
{(() => {
|
||||
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 <polyline points={points} fill="none" stroke="#aa88ff" strokeWidth="1.5" />;
|
||||
})()}
|
||||
</svg>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<div style={{ textAlign: 'center', color: 'var(--text-muted)', padding: '20px' }}>
|
||||
Loading solar data...
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
) : (
|
||||
/* Solar Image View */
|
||||
<div style={{ textAlign: 'center' }}>
|
||||
<img
|
||||
src={imageUrl}
|
||||
alt="SDO Solar Image"
|
||||
style={{
|
||||
width: '100%',
|
||||
maxWidth: '200px',
|
||||
borderRadius: '50%',
|
||||
border: '2px solid var(--border-color)'
|
||||
}}
|
||||
onError={(e) => {
|
||||
e.target.style.display = 'none';
|
||||
}}
|
||||
/>
|
||||
<div style={{ fontSize: '10px', color: 'var(--text-muted)', marginTop: '4px' }}>
|
||||
SDO/AIA • Live from NASA
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default SolarPanel;
|
||||
Loading…
Reference in new issue