dxpedition update

pull/27/head
accius 4 days ago
parent c312ce66e0
commit 735766a20e

@ -1092,6 +1092,37 @@
return { data, loading };
};
// ============================================
// DXPEDITION TRACKING HOOK
// ============================================
const useDXpeditions = () => {
const [data, setData] = useState(null);
const [loading, setLoading] = useState(true);
useEffect(() => {
const fetchDXpeditions = async () => {
try {
const response = await fetch('/api/dxpeditions');
if (response.ok) {
const result = await response.json();
setData(result);
}
} catch (err) {
console.error('DXpedition fetch error:', err);
} finally {
setLoading(false);
}
};
fetchDXpeditions();
// Refresh every 30 minutes
const interval = setInterval(fetchDXpeditions, 30 * 60 * 1000);
return () => clearInterval(interval);
}, []);
return { data, loading };
};
// ============================================
// SATELLITE TRACKING HOOK
// ============================================
@ -2542,6 +2573,90 @@
</div>
);
// ============================================
// DXPEDITION PANEL
// ============================================
const DXpeditionPanel = ({ data, loading }) => {
const formatDate = (isoString) => {
if (!isoString) return '';
const date = new Date(isoString);
return date.toLocaleDateString('en-US', { month: 'short', day: 'numeric' });
};
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' }}>{exp.callsign}</span>
<span style={{ color: style.color, fontSize: '10px' }}>
{exp.isActive ? '● ACTIVE' : exp.isUpcoming ? 'UPCOMING' : 'PAST'}
</span>
</div>
<div style={{ color: 'var(--text-secondary)', marginTop: '2px' }}>
{exp.entity}
</div>
<div style={{ display: 'flex', justifyContent: 'space-between', marginTop: '2px' }}>
<span style={{ color: 'var(--text-muted)', fontSize: '11px' }}>{exp.dates}</span>
{exp.qsl && <span style={{ color: 'var(--text-muted)', fontSize: '10px' }}>{exp.qsl.substring(0, 15)}</span>}
</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>
);
};
// ============================================
// CONTEST CALENDAR PANEL
// ============================================
@ -2635,7 +2750,7 @@
const LegacyLayout = ({
config, currentTime, utcTime, utcDate, localTime, localDate,
deGrid, dxGrid, deSunTimes, dxSunTimes, dxLocation, onDXChange,
spaceWeather, bandConditions, solarIndices, potaSpots, dxCluster, dxPaths, contests, propagation, mySpots, satellites,
spaceWeather, bandConditions, solarIndices, potaSpots, dxCluster, dxPaths, dxpeditions, contests, propagation, mySpots, satellites,
localWeather, use12Hour, onTimeFormatToggle,
onSettingsClick, onFullscreenToggle, isFullscreen,
mapLayers, toggleDXPaths, togglePOTA, toggleSatellites
@ -2936,6 +3051,40 @@
</div>
</div>
{/* DXpeditions */}
<div style={{ marginTop: '8px' }}>
<div style={{ ...labelStyle, marginBottom: '8px', display: 'flex', justifyContent: 'space-between', alignItems: 'center' }}>
<span>🌍 DXPEDITIONS</span>
{dxpeditions.data?.active > 0 && (
<span style={{ fontSize: '9px', color: 'var(--accent-green)' }}>{dxpeditions.data.active} active</span>
)}
</div>
<div style={{ maxHeight: '120px', overflowY: 'auto' }}>
{dxpeditions.data?.dxpeditions?.slice(0, 6).map((exp, i) => (
<div key={i} style={{
padding: '3px 0',
borderBottom: '1px solid rgba(255,255,255,0.05)',
fontSize: '12px',
borderLeft: exp.isActive ? '2px solid var(--accent-green)' : 'none',
paddingLeft: exp.isActive ? '6px' : '0'
}}>
<div style={{ display: 'flex', justifyContent: 'space-between' }}>
<span style={{ color: 'var(--accent-amber)', fontWeight: '600' }}>{exp.callsign}</span>
<span style={{ color: exp.isActive ? 'var(--accent-green)' : 'var(--text-muted)', fontSize: '10px' }}>
{exp.isActive ? '● NOW' : ''}
</span>
</div>
<div style={{ color: 'var(--text-muted)', fontSize: '11px' }}>{exp.entity}</div>
</div>
))}
{!dxpeditions.data?.dxpeditions?.length && (
<div style={{ color: 'var(--text-muted)', fontSize: '11px' }}>
{dxpeditions.loading ? 'Loading...' : 'No data'}
</div>
)}
</div>
</div>
{/* Contests */}
<div style={{ marginTop: '8px' }}>
<div style={{ ...labelStyle, marginBottom: '8px' }}>🏆 CONTESTS</div>
@ -3508,6 +3657,7 @@
const potaSpots = usePOTASpots();
const dxCluster = useDXCluster(config.dxClusterSource || 'auto');
const dxPaths = useDXPaths();
const dxpeditions = useDXpeditions();
const contests = useContests();
const propagation = usePropagation(config.location, dxLocation);
const mySpots = useMySpots(config.callsign);
@ -3588,6 +3738,7 @@
potaSpots={potaSpots}
dxCluster={dxCluster}
dxPaths={dxPaths}
dxpeditions={dxpeditions}
contests={contests}
propagation={propagation}
mySpots={mySpots}
@ -3816,6 +3967,47 @@
</div>
</div>
{/* DXpeditions - Compact */}
<div className="panel" style={{ padding: '10px', flex: '0 0 auto', maxHeight: '150px', overflow: 'hidden' }}>
<div style={{ fontSize: '12px', color: 'var(--accent-cyan)', fontWeight: '700', marginBottom: '6px', display: 'flex', justifyContent: 'space-between', alignItems: 'center' }}>
<span>🌍 DXPEDITIONS</span>
{dxpeditions.data && (
<span style={{ fontSize: '9px', color: 'var(--text-muted)', fontWeight: '400' }}>
{dxpeditions.data.active > 0 && <span style={{ color: 'var(--accent-green)' }}>{dxpeditions.data.active} active</span>}
</span>
)}
</div>
<div style={{ overflow: 'auto', maxHeight: '110px', fontSize: '11px', fontFamily: 'JetBrains Mono' }}>
{dxpeditions.data?.dxpeditions?.slice(0, 8).map((exp, i) => (
<div key={i} style={{
padding: '3px 0',
borderBottom: '1px solid rgba(255,255,255,0.05)',
borderLeft: exp.isActive ? '2px solid var(--accent-green)' : exp.isUpcoming ? '2px solid var(--accent-cyan)' : 'none',
paddingLeft: (exp.isActive || exp.isUpcoming) ? '6px' : '0'
}}>
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center' }}>
<span style={{ color: 'var(--accent-amber)', fontWeight: '600' }}>{exp.callsign}</span>
<span style={{ color: exp.isActive ? 'var(--accent-green)' : 'var(--text-muted)', fontSize: '9px' }}>
{exp.isActive ? '● NOW' : exp.dates?.split('-')[0] || ''}
</span>
</div>
<div style={{ color: 'var(--text-secondary)', fontSize: '10px', whiteSpace: 'nowrap', overflow: 'hidden', textOverflow: 'ellipsis' }}>
{exp.entity}
</div>
</div>
))}
{(!dxpeditions.data?.dxpeditions || dxpeditions.data.dxpeditions.length === 0) &&
<div style={{ color: 'var(--text-muted)' }}>{dxpeditions.loading ? 'Loading...' : 'No DXpeditions'}</div>
}
</div>
<div style={{ marginTop: '4px', textAlign: 'right' }}>
<a href="https://www.ng3k.com/misc/adxo.html" target="_blank" rel="noopener noreferrer"
style={{ fontSize: '8px', color: 'var(--text-muted)', textDecoration: 'none' }}>
NG3K ADXO
</a>
</div>
</div>
{/* POTA - Compact */}
<div className="panel" style={{ padding: '10px', flex: '0 0 auto' }}>
<div style={{ fontSize: '12px', color: 'var(--accent-amber)', fontWeight: '700', marginBottom: '6px', display: 'flex', justifyContent: 'space-between', alignItems: 'center' }}>

@ -145,6 +145,144 @@ app.get('/api/solar-indices', async (req, res) => {
}
});
// DXpedition Calendar - fetches from NG3K ADXO
let dxpeditionCache = { data: null, timestamp: 0, maxAge: 30 * 60 * 1000 }; // 30 min cache
app.get('/api/dxpeditions', async (req, res) => {
try {
const now = Date.now();
// Return cached data if fresh
if (dxpeditionCache.data && (now - dxpeditionCache.timestamp) < dxpeditionCache.maxAge) {
return res.json(dxpeditionCache.data);
}
// Fetch NG3K ADXO page
const response = await fetch('https://www.ng3k.com/misc/adxo.html');
if (!response.ok) throw new Error('Failed to fetch NG3K');
const html = await response.text();
const dxpeditions = [];
// Parse the HTML table - NG3K uses a specific format
// Look for table rows with DXpedition data
const tableMatch = html.match(/<table[^>]*>[\s\S]*?<\/table>/gi);
if (tableMatch) {
// Find the main data table (usually the largest one)
for (const table of tableMatch) {
const rows = table.match(/<tr[^>]*>[\s\S]*?<\/tr>/gi);
if (!rows || rows.length < 5) continue;
for (const row of rows) {
// Skip header rows
if (row.includes('<th') || row.includes('CALLSIGN') || row.includes('ENTITY')) continue;
// Extract cells
const cells = row.match(/<td[^>]*>([\s\S]*?)<\/td>/gi);
if (!cells || cells.length < 4) continue;
// Clean cell content
const cleanCell = (cell) => {
return cell
.replace(/<[^>]*>/g, '') // Remove HTML tags
.replace(/&nbsp;/g, ' ')
.replace(/&amp;/g, '&')
.replace(/&lt;/g, '<')
.replace(/&gt;/g, '>')
.trim();
};
const callsign = cleanCell(cells[0] || '');
const entity = cleanCell(cells[1] || '');
const dates = cleanCell(cells[2] || '');
const qsl = cleanCell(cells[3] || '');
// Skip if no valid callsign
if (!callsign || callsign.length < 2 || callsign.includes('CALLSIGN')) continue;
// Parse dates (format varies: "Jan 15-Feb 28" or "2024 Jan 15-Feb 28")
let startDate = null;
let endDate = null;
let isActive = false;
let isUpcoming = false;
const dateMatch = dates.match(/(\w+)\s+(\d+)[\s\-]+(\w+)?\s*(\d+)?/);
if (dateMatch) {
const year = new Date().getFullYear();
const monthNames = ['jan', 'feb', 'mar', 'apr', 'may', 'jun', 'jul', 'aug', 'sep', 'oct', 'nov', 'dec'];
const startMonth = monthNames.indexOf(dateMatch[1].toLowerCase().substring(0, 3));
const startDay = parseInt(dateMatch[2]);
const endMonth = dateMatch[3] ? monthNames.indexOf(dateMatch[3].toLowerCase().substring(0, 3)) : startMonth;
const endDay = parseInt(dateMatch[4]) || startDay + 7;
if (startMonth >= 0) {
startDate = new Date(year, startMonth, startDay);
endDate = new Date(year, endMonth >= 0 ? endMonth : startMonth, endDay);
// Handle year rollover
if (endDate < startDate) {
endDate.setFullYear(year + 1);
}
const today = new Date();
today.setHours(0, 0, 0, 0);
isActive = startDate <= today && endDate >= today;
isUpcoming = startDate > today;
}
}
dxpeditions.push({
callsign,
entity,
dates,
qsl,
startDate: startDate?.toISOString(),
endDate: endDate?.toISOString(),
isActive,
isUpcoming
});
}
}
}
// Sort: active first, then upcoming by start date, then past
dxpeditions.sort((a, b) => {
if (a.isActive && !b.isActive) return -1;
if (!a.isActive && b.isActive) return 1;
if (a.isUpcoming && !b.isUpcoming) return -1;
if (!a.isUpcoming && b.isUpcoming) return 1;
if (a.startDate && b.startDate) return new Date(a.startDate) - new Date(b.startDate);
return 0;
});
const result = {
dxpeditions: dxpeditions.slice(0, 50), // Limit to 50 entries
active: dxpeditions.filter(d => d.isActive).length,
upcoming: dxpeditions.filter(d => d.isUpcoming).length,
source: 'NG3K ADXO',
timestamp: new Date().toISOString()
};
// Cache the result
dxpeditionCache.data = result;
dxpeditionCache.timestamp = now;
res.json(result);
} catch (error) {
console.error('DXpedition API error:', error.message);
// Return cached data if available, even if stale
if (dxpeditionCache.data) {
return res.json({ ...dxpeditionCache.data, stale: true });
}
res.status(500).json({ error: 'Failed to fetch DXpedition data' });
}
});
// NOAA Space Weather - X-Ray Flux
app.get('/api/noaa/xray', async (req, res) => {
try {

Loading…
Cancel
Save

Powered by TurnKey Linux.