Files
ShackMaster/web/src/components/StatusBanner.svelte
2026-01-16 01:28:28 +01:00

741 lines
19 KiB
Svelte

<script>
import { onMount, onDestroy } from 'svelte';
export let flexradio = null;
export let weather = null;
export let latitude = null;
export let longitude = null;
export let windWarningThreshold = 30; // km/h
export let gustWarningThreshold = 50; // km/h
export let graylineWindow = 30; // minutes avant/après sunrise/sunset
// FlexRadio status
$: frequency = flexradio?.frequency || 0;
$: mode = flexradio?.mode || '';
$: txEnabled = flexradio?.tx || false;
$: connected = flexradio?.connected || false;
$: radioOn = flexradio?.radio_on || false;
$: radioInfo = flexradio?.radio_info || '';
$: callsign = flexradio?.callsign || '';
$: model = flexradio?.model || '';
$: activeSlices = flexradio?.active_slices || 0;
// Grayline calculation
let sunrise = null;
let sunset = null;
let isGrayline = false;
let graylineType = ''; // 'sunrise' ou 'sunset'
let timeToNextEvent = '';
let currentTime = new Date();
let clockInterval;
// Update time every minute for grayline check
onMount(() => {
calculateSunTimes();
clockInterval = setInterval(() => {
currentTime = new Date();
checkGrayline();
updateTimeToNextEvent();
}, 10000); // Update every 10 seconds
});
onDestroy(() => {
if (clockInterval) clearInterval(clockInterval);
});
// Recalculate when location changes
$: if (latitude && longitude) {
calculateSunTimes();
}
// SunCalc algorithm (simplified version)
function calculateSunTimes() {
if (!latitude || !longitude) return;
const now = new Date();
const times = getSunTimes(now, latitude, longitude);
sunrise = times.sunrise;
sunset = times.sunset;
checkGrayline();
updateTimeToNextEvent();
}
$: console.log('FlexRadio status:', {
connected,
radioOn,
frequency,
activeSlices,
radioInfo,
callsign,
model
});
// Simplified sun calculation (based on NOAA algorithm)
function getSunTimes(date, lat, lon) {
const rad = Math.PI / 180;
const dayOfYear = getDayOfYear(date);
// Fractional year
const gamma = (2 * Math.PI / 365) * (dayOfYear - 1 + (date.getHours() - 12) / 24);
// Equation of time (minutes)
const eqTime = 229.18 * (0.000075 + 0.001868 * Math.cos(gamma) - 0.032077 * Math.sin(gamma)
- 0.014615 * Math.cos(2 * gamma) - 0.040849 * Math.sin(2 * gamma));
// Solar declination (radians)
const decl = 0.006918 - 0.399912 * Math.cos(gamma) + 0.070257 * Math.sin(gamma)
- 0.006758 * Math.cos(2 * gamma) + 0.000907 * Math.sin(2 * gamma)
- 0.002697 * Math.cos(3 * gamma) + 0.00148 * Math.sin(3 * gamma);
// Hour angle for sunrise/sunset
const latRad = lat * rad;
const zenith = 90.833 * rad; // Official zenith for sunrise/sunset
const cosHA = (Math.cos(zenith) / (Math.cos(latRad) * Math.cos(decl)))
- Math.tan(latRad) * Math.tan(decl);
// Check for polar day/night
if (cosHA > 1 || cosHA < -1) {
return { sunrise: null, sunset: null };
}
const ha = Math.acos(cosHA) / rad; // Hour angle in degrees
// Sunrise and sunset times in minutes from midnight UTC
const sunriseMinutes = 720 - 4 * (lon + ha) - eqTime;
const sunsetMinutes = 720 - 4 * (lon - ha) - eqTime;
// Convert to local Date objects
const sunriseDate = new Date(date);
sunriseDate.setUTCHours(0, 0, 0, 0);
sunriseDate.setUTCMinutes(sunriseMinutes);
const sunsetDate = new Date(date);
sunsetDate.setUTCHours(0, 0, 0, 0);
sunsetDate.setUTCMinutes(sunsetMinutes);
return { sunrise: sunriseDate, sunset: sunsetDate };
}
function getDayOfYear(date) {
const start = new Date(date.getFullYear(), 0, 0);
const diff = date - start;
const oneDay = 1000 * 60 * 60 * 24;
return Math.floor(diff / oneDay);
}
function checkGrayline() {
if (!sunrise || !sunset) {
isGrayline = false;
return;
}
const now = currentTime.getTime();
const windowMs = graylineWindow * 60 * 1000;
const nearSunrise = Math.abs(now - sunrise.getTime()) <= windowMs;
const nearSunset = Math.abs(now - sunset.getTime()) <= windowMs;
isGrayline = nearSunrise || nearSunset;
graylineType = nearSunrise ? 'sunrise' : (nearSunset ? 'sunset' : '');
}
function updateTimeToNextEvent() {
if (!sunrise || !sunset) {
timeToNextEvent = '';
return;
}
const now = currentTime.getTime();
let nextEvent = null;
let eventName = '';
if (now < sunrise.getTime()) {
nextEvent = sunrise;
eventName = 'Sunrise';
} else if (now < sunset.getTime()) {
nextEvent = sunset;
eventName = 'Sunset';
} else {
// After sunset, calculate tomorrow's sunrise
const tomorrow = new Date(currentTime);
tomorrow.setDate(tomorrow.getDate() + 1);
const tomorrowTimes = getSunTimes(tomorrow, latitude, longitude);
nextEvent = tomorrowTimes.sunrise;
eventName = 'Sunrise';
}
if (nextEvent) {
const diffMs = nextEvent.getTime() - now;
const hours = Math.floor(diffMs / (1000 * 60 * 60));
const minutes = Math.floor((diffMs % (1000 * 60 * 60)) / (1000 * 60));
if (hours > 0) {
timeToNextEvent = `${eventName} in ${hours}h${minutes.toString().padStart(2, '0')}m`;
} else {
timeToNextEvent = `${eventName} in ${minutes}m`;
}
}
}
function formatTime(date) {
if (!date) return '--:--';
return date.toLocaleTimeString('fr-FR', { hour: '2-digit', minute: '2-digit' });
}
// Format frequency for display (MHz with appropriate decimals)
function formatFrequency(freqMHz) {
if (!freqMHz || freqMHz === 0) return '---';
if (freqMHz < 10) {
return freqMHz.toFixed(4);
} else if (freqMHz < 100) {
return freqMHz.toFixed(3);
} else {
return freqMHz.toFixed(2);
}
}
// Get band from frequency
function getBand(freqMHz) {
if (!freqMHz || freqMHz === 0) return '';
if (freqMHz >= 1.8 && freqMHz <= 2.0) return '160M';
if (freqMHz >= 3.5 && freqMHz <= 4.0) return '80M';
if (freqMHz >= 5.3 && freqMHz <= 5.4) return '60M';
if (freqMHz >= 7.0 && freqMHz <= 7.3) return '40M';
if (freqMHz >= 10.1 && freqMHz <= 10.15) return '30M';
if (freqMHz >= 14.0 && freqMHz <= 14.35) return '20M';
if (freqMHz >= 18.068 && freqMHz <= 18.168) return '17M';
if (freqMHz >= 21.0 && freqMHz <= 21.45) return '15M';
if (freqMHz >= 24.89 && freqMHz <= 24.99) return '12M';
if (freqMHz >= 28.0 && freqMHz <= 29.7) return '10M';
if (freqMHz >= 50.0 && freqMHz <= 54.0) return '6M';
if (freqMHz >= 144.0 && freqMHz <= 148.0) return '2M';
if (freqMHz >= 430.0 && freqMHz <= 440.0) return '70CM';
return '';
}
// Weather alerts
$: windSpeed = weather?.wind_speed || 0;
$: windGust = weather?.wind_gust || 0;
$: hasWindWarning = windSpeed >= windWarningThreshold;
$: hasGustWarning = windGust >= gustWarningThreshold;
$: hasAnyWarning = hasWindWarning || hasGustWarning;
// Band colors
function getBandColor(band) {
const colors = {
'160M': '#9c27b0',
'80M': '#673ab7',
'60M': '#3f51b5',
'40M': '#2196f3',
'30M': '#00bcd4',
'20M': '#009688',
'17M': '#4caf50',
'15M': '#8bc34a',
'12M': '#cddc39',
'10M': '#ffeb3b',
'6M': '#ff9800',
'2M': '#ff5722',
'70CM': '#f44336'
};
return colors[band] || '#4fc3f7';
}
$: currentBand = getBand(frequency);
$: bandColor = getBandColor(currentBand);
// Determine what to show for FlexRadio - MODIFIÉ
$: showFrequency = radioOn && frequency > 0;
$: showRadioOnNoSlice = radioOn && frequency === 0 && activeSlices === 0;
$: showRadioOnWithSliceNoFreq = radioOn && frequency === 0 && activeSlices > 0;
$: showNotConnected = !connected;
$: showConnectedNoRadio = connected && !radioOn;
</script>
<div class="status-banner" class:has-warning={hasAnyWarning}>
<!-- FlexRadio Section -->
<div class="flex-section">
<div class="flex-icon" class:connected={connected} class:disconnected={!connected}>
📻
</div>
{#if showFrequency}
<!-- Radio is on and has active slice with frequency -->
<div class="frequency-display">
<span class="frequency" style="--band-color: {bandColor}">
{formatFrequency(frequency)}
</span>
<span class="unit">MHz</span>
</div>
{#if currentBand}
<span class="band-badge" style="background-color: {bandColor}">
{currentBand}
</span>
{/if}
{#if mode}
<span class="mode-badge">
{mode}
</span>
{/if}
<!-- AFFICHAGE TX - SEULEMENT SI TX EST VRAI -->
{#if txEnabled}
<span class="tx-indicator">
TX
</span>
{/if}
{:else if showRadioOnWithSliceNoFreq}
<!-- Radio is on with slice but frequency is 0 (maybe slice just created) -->
<div class="radio-status">
<span class="radio-on-indicator"></span>
<span class="radio-status-text">Slice active, waiting for frequency...</span>
{#if model}
<span class="model-badge">{model}</span>
{/if}
{#if callsign}
<span class="callsign-badge">{callsign}</span>
{/if}
</div>
{:else if showRadioOnNoSlice}
<!-- Radio is on but no active slice -->
<div class="radio-status">
<span class="radio-on-indicator"></span>
<span class="radio-status-text">{radioInfo || 'Radio is on'}</span>
{#if model}
<span class="model-badge">{model}</span>
{/if}
{#if callsign}
<span class="callsign-badge">{callsign}</span>
{/if}
</div>
{:else if showConnectedNoRadio}
<!-- TCP connected but radio not responding -->
<div class="radio-status">
<span class="radio-off-indicator"></span>
<span class="radio-status-text">TCP connected, radio off</span>
</div>
{:else if showNotConnected}
<!-- Not connected at all -->
<span class="no-signal">FlexRadio not connected</span>
{:else}
<!-- Default/unknown state -->
<span class="no-signal">Checking FlexRadio...</span>
{/if}
</div>
<!-- Separator -->
<div class="separator"></div>
<!-- Grayline Section -->
<div class="grayline-section">
{#if latitude && longitude}
<div class="sun-times">
<span class="sun-item" title="Sunrise">
<svg class="sun-icon sunrise-icon" width="18" height="18" viewBox="0 0 24 24">
<!-- Horizon line -->
<line x1="2" y1="18" x2="22" y2="18" stroke="currentColor" stroke-width="1.5" opacity="0.5"/>
<!-- Sun (half visible) -->
<path d="M12 18 A6 6 0 0 1 12 6 A6 6 0 0 1 12 18" fill="#fbbf24"/>
<!-- Rays -->
<path d="M12 2v3M5.6 5.6l2.1 2.1M2 12h3M19 12h3M18.4 5.6l-2.1 2.1" stroke="#fbbf24" stroke-width="2" stroke-linecap="round"/>
<!-- Up arrow -->
<path d="M12 14l-3 3M12 14l3 3M12 14v5" stroke="#22c55e" stroke-width="2" stroke-linecap="round" fill="none"/>
</svg>
{formatTime(sunrise)}
</span>
<span class="sun-item" title="Sunset">
<svg class="sun-icon sunset-icon" width="18" height="18" viewBox="0 0 24 24">
<!-- Horizon line -->
<line x1="2" y1="18" x2="22" y2="18" stroke="currentColor" stroke-width="1.5" opacity="0.5"/>
<!-- Sun (half visible, setting) -->
<path d="M12 18 A6 6 0 0 1 12 6 A6 6 0 0 1 12 18" fill="#f97316"/>
<!-- Rays (dimmer) -->
<path d="M12 2v3M5.6 5.6l2.1 2.1M2 12h3M19 12h3M18.4 5.6l-2.1 2.1" stroke="#f97316" stroke-width="2" stroke-linecap="round" opacity="0.7"/>
<!-- Down arrow -->
<path d="M12 22l-3-3M12 22l3-3M12 22v-5" stroke="#ef4444" stroke-width="2" stroke-linecap="round" fill="none"/>
</svg>
{formatTime(sunset)}
</span>
</div>
{#if isGrayline}
<span class="grayline-badge" class:sunrise={graylineType === 'sunrise'} class:sunset={graylineType === 'sunset'}>
✨ Grayline
</span>
{:else if timeToNextEvent}
<span class="next-event">
{timeToNextEvent}
</span>
{/if}
{:else}
<span class="no-location">📍 Position not set</span>
{/if}
</div>
<!-- Separator -->
<div class="separator"></div>
<!-- Weather Alerts Section -->
<div class="weather-section">
{#if hasWindWarning}
<div class="alert wind-alert">
<span class="alert-icon">⚠️</span>
<span class="alert-text">
Vent: <strong>{windSpeed.toFixed(0)} km/h</strong>
</span>
</div>
{/if}
{#if hasGustWarning}
<div class="alert gust-alert">
<span class="alert-icon">🌪️</span>
<span class="alert-text">
Rafales: <strong>{windGust.toFixed(0)} km/h</strong>
</span>
</div>
{/if}
{#if !hasAnyWarning}
<div class="status-ok">
<span class="ok-icon"></span>
<span class="ok-text">Weather OK</span>
</div>
{/if}
</div>
</div>
<style>
.status-banner {
display: flex;
align-items: center;
justify-content: space-between;
padding: 10px 24px;
background: linear-gradient(135deg, #1a1a2e 0%, #16213e 50%, #0f3460 100%);
border-bottom: 1px solid rgba(79, 195, 247, 0.15);
gap: 20px;
flex-wrap: wrap;
}
.status-banner.has-warning {
background: linear-gradient(135deg, #1f1a12 0%, #29201b 50%, #2d1b0c 100%);
border-bottom-color: #f59e0b;
}
/* FlexRadio Section */
.flex-section {
display: flex;
align-items: center;
gap: 12px;
}
.flex-icon {
font-size: 20px;
opacity: 0.8;
}
.flex-icon.connected {
opacity: 1;
filter: drop-shadow(0 0 4px rgba(79, 195, 247, 0.6));
}
.flex-icon.disconnected {
opacity: 0.4;
filter: grayscale(1);
}
.frequency-display {
display: flex;
align-items: baseline;
gap: 4px;
}
.frequency {
font-size: 28px;
font-weight: 300;
font-family: 'Roboto Mono', 'Consolas', monospace;
color: var(--band-color, #4fc3f7);
text-shadow: 0 0 15px var(--band-color, rgba(79, 195, 247, 0.5));
letter-spacing: 1px;
}
.unit {
font-size: 14px;
color: rgba(255, 255, 255, 0.5);
font-weight: 400;
}
.band-badge {
padding: 4px 10px;
border-radius: 4px;
font-size: 13px;
font-weight: 700;
color: #000;
text-shadow: none;
}
.mode-badge {
padding: 4px 10px;
background: rgba(156, 39, 176, 0.3);
border: 1px solid rgba(156, 39, 176, 0.5);
border-radius: 4px;
font-size: 13px;
font-weight: 600;
color: #ce93d8;
}
.tx-indicator {
padding: 4px 10px;
background: rgba(244, 67, 54, 0.3);
border: 1px solid #f44336;
border-radius: 4px;
font-size: 13px;
font-weight: 700;
color: #f44336;
text-shadow: 0 0 10px rgba(244, 67, 54, 0.8);
animation: txPulse 0.5s ease-in-out infinite;
}
@keyframes txPulse {
0%, 100% { opacity: 1; }
50% { opacity: 0.6; }
}
.slice-waiting {
color: #fbbf24; /* Jaune pour "en attente" */
animation: pulse 1.5s infinite;
}
/* Radio status indicators */
.radio-status {
display: flex;
align-items: center;
gap: 8px;
}
.radio-on-indicator {
color: #22c55e;
font-size: 16px;
animation: pulse 2s infinite;
}
.radio-off-indicator {
color: #ef4444;
font-size: 16px;
}
@keyframes pulse {
0%, 100% { opacity: 1; }
50% { opacity: 0.5; }
}
.radio-status-text {
color: rgba(255, 255, 255, 0.9);
font-size: 14px;
}
.model-badge {
padding: 3px 8px;
background: rgba(79, 195, 247, 0.2);
border: 1px solid rgba(79, 195, 247, 0.4);
border-radius: 4px;
font-size: 12px;
color: #4fc3f7;
}
.callsign-badge {
padding: 3px 8px;
background: rgba(34, 197, 94, 0.2);
border: 1px solid rgba(34, 197, 94, 0.4);
border-radius: 4px;
font-size: 12px;
font-weight: 600;
color: #22c55e;
}
.no-signal {
color: rgba(255, 255, 255, 0.4);
font-size: 14px;
font-style: italic;
}
/* Separator */
.separator {
width: 1px;
height: 30px;
background: rgba(255, 255, 255, 0.2);
}
/* Grayline Section */
.grayline-section {
display: flex;
align-items: center;
gap: 12px;
}
.sun-times {
display: flex;
gap: 12px;
}
.sun-item {
font-size: 14px;
color: rgba(255, 255, 255, 0.85);
display: flex;
align-items: center;
gap: 6px;
}
.sun-icon {
flex-shrink: 0;
}
.sunrise-icon {
color: rgba(251, 191, 36, 0.6);
filter: drop-shadow(0 0 4px rgba(251, 191, 36, 0.4));
}
.sunset-icon {
color: rgba(249, 115, 22, 0.6);
filter: drop-shadow(0 0 4px rgba(249, 115, 22, 0.4));
}
.grayline-badge {
padding: 5px 12px;
border-radius: 4px;
font-size: 12px;
font-weight: 700;
letter-spacing: 1px;
animation: graylinePulse 1.5s ease-in-out infinite;
}
.grayline-badge.sunrise {
background: linear-gradient(135deg, rgba(255, 183, 77, 0.3) 0%, rgba(255, 138, 101, 0.3) 100%);
border: 1px solid rgba(255, 183, 77, 0.6);
color: #ffcc80;
text-shadow: 0 0 10px rgba(255, 183, 77, 0.8);
}
.grayline-badge.sunset {
background: linear-gradient(135deg, rgba(255, 138, 101, 0.3) 0%, rgba(239, 83, 80, 0.3) 100%);
border: 1px solid rgba(255, 138, 101, 0.6);
color: #ffab91;
text-shadow: 0 0 10px rgba(255, 138, 101, 0.8);
}
@keyframes graylinePulse {
0%, 100% { opacity: 1; transform: scale(1); }
50% { opacity: 0.85; transform: scale(1.02); }
}
.next-event {
font-size: 12px;
color: rgba(255, 255, 255, 0.5);
font-style: italic;
}
.no-location {
font-size: 13px;
color: rgba(255, 255, 255, 0.4);
font-style: italic;
}
/* Weather Section */
.weather-section {
display: flex;
align-items: center;
gap: 16px;
}
.alert {
display: flex;
align-items: center;
gap: 8px;
padding: 6px 14px;
border-radius: 6px;
animation: alertPulse 2s ease-in-out infinite;
}
.wind-alert {
background: rgba(245, 158, 11, 0.2);
border: 1px solid rgba(245, 158, 11, 0.5);
}
.gust-alert {
background: rgba(239, 68, 68, 0.2);
border: 1px solid rgba(239, 68, 68, 0.5);
}
@keyframes alertPulse {
0%, 100% { opacity: 1; }
50% { opacity: 0.8; }
}
.alert-icon {
font-size: 16px;
}
.alert-text {
font-size: 13px;
color: rgba(255, 255, 255, 0.9);
}
.alert-text strong {
color: #fbbf24;
font-weight: 700;
}
.gust-alert .alert-text strong {
color: #f87171;
}
.status-ok {
display: flex;
align-items: center;
gap: 6px;
padding: 6px 14px;
background: rgba(34, 197, 94, 0.1);
border: 1px solid rgba(34, 197, 94, 0.3);
border-radius: 6px;
}
.ok-icon {
color: #22c55e;
font-weight: 700;
}
.ok-text {
font-size: 13px;
color: rgba(34, 197, 94, 0.9);
}
/* Responsive */
@media (max-width: 768px) {
.status-banner {
padding: 8px 16px;
gap: 12px;
}
.frequency {
font-size: 22px;
}
.separator {
display: none;
}
.flex-section,
.grayline-section,
.weather-section {
width: 100%;
justify-content: center;
}
}
</style>