@@ -32,9 +32,6 @@ type Client struct {
|
|||||||
// Callbacks
|
// Callbacks
|
||||||
onFrequencyChange func(freqMHz float64)
|
onFrequencyChange func(freqMHz float64)
|
||||||
checkTransmitAllowed func() bool // Returns true if transmit allowed (motors not moving)
|
checkTransmitAllowed func() bool // Returns true if transmit allowed (motors not moving)
|
||||||
|
|
||||||
// Reconnection settings
|
|
||||||
reconnectInterval time.Duration
|
|
||||||
}
|
}
|
||||||
|
|
||||||
func New(host string, port int) *Client {
|
func New(host string, port int) *Client {
|
||||||
@@ -45,7 +42,6 @@ func New(host string, port int) *Client {
|
|||||||
lastStatus: &Status{
|
lastStatus: &Status{
|
||||||
Connected: false,
|
Connected: false,
|
||||||
},
|
},
|
||||||
reconnectInterval: 5 * time.Second, // Reconnect every 5 seconds if disconnected
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -72,7 +68,6 @@ func (c *Client) Connect() error {
|
|||||||
|
|
||||||
conn, err := net.DialTimeout("tcp", addr, 5*time.Second)
|
conn, err := net.DialTimeout("tcp", addr, 5*time.Second)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
log.Printf("FlexRadio: Connection failed: %v", err)
|
|
||||||
return fmt.Errorf("failed to connect: %w", err)
|
return fmt.Errorf("failed to connect: %w", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -88,14 +83,10 @@ func (c *Client) Start() error {
|
|||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
c.running = true
|
if err := c.Connect(); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
// Try initial connection but don't fail if it doesn't work
|
|
||||||
// The messageLoop will handle reconnection
|
|
||||||
err := c.Connect()
|
|
||||||
if err != nil {
|
|
||||||
log.Printf("FlexRadio: Initial connection failed, will retry: %v", err)
|
|
||||||
} else {
|
|
||||||
// Update connected status
|
// Update connected status
|
||||||
c.statusMu.Lock()
|
c.statusMu.Lock()
|
||||||
if c.lastStatus != nil {
|
if c.lastStatus != nil {
|
||||||
@@ -103,22 +94,19 @@ func (c *Client) Start() error {
|
|||||||
}
|
}
|
||||||
c.statusMu.Unlock()
|
c.statusMu.Unlock()
|
||||||
|
|
||||||
// Subscribe to slice updates for frequency tracking
|
c.running = true
|
||||||
c.subscribeToSlices()
|
|
||||||
}
|
|
||||||
|
|
||||||
// Start message listener (handles reconnection)
|
// Start message listener
|
||||||
go c.messageLoop()
|
go c.messageLoop()
|
||||||
|
|
||||||
return nil
|
// Subscribe to slice updates for frequency tracking
|
||||||
}
|
|
||||||
|
|
||||||
func (c *Client) subscribeToSlices() {
|
|
||||||
log.Println("FlexRadio: Subscribing to slice updates...")
|
log.Println("FlexRadio: Subscribing to slice updates...")
|
||||||
_, err := c.sendCommand("sub slice all")
|
_, err := c.sendCommand("sub slice all")
|
||||||
if err != nil {
|
if err != nil {
|
||||||
log.Printf("FlexRadio: Warning - failed to subscribe to slices: %v", err)
|
log.Printf("FlexRadio: Warning - failed to subscribe to slices: %v", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func (c *Client) Stop() {
|
func (c *Client) Stop() {
|
||||||
@@ -185,52 +173,15 @@ func (c *Client) sendCommand(cmd string) (string, error) {
|
|||||||
func (c *Client) messageLoop() {
|
func (c *Client) messageLoop() {
|
||||||
log.Println("FlexRadio: Message loop started")
|
log.Println("FlexRadio: Message loop started")
|
||||||
|
|
||||||
reconnectTicker := time.NewTicker(c.reconnectInterval)
|
|
||||||
defer reconnectTicker.Stop()
|
|
||||||
|
|
||||||
for c.running {
|
for c.running {
|
||||||
c.connMu.Lock()
|
c.connMu.Lock()
|
||||||
isConnected := c.conn != nil && c.reader != nil
|
if c.conn == nil || c.reader == nil {
|
||||||
c.connMu.Unlock()
|
c.connMu.Unlock()
|
||||||
|
time.Sleep(1 * time.Second)
|
||||||
if !isConnected {
|
|
||||||
// Update status to disconnected
|
|
||||||
c.statusMu.Lock()
|
|
||||||
if c.lastStatus != nil {
|
|
||||||
c.lastStatus.Connected = false
|
|
||||||
}
|
|
||||||
c.statusMu.Unlock()
|
|
||||||
|
|
||||||
// Wait for reconnect interval
|
|
||||||
select {
|
|
||||||
case <-reconnectTicker.C:
|
|
||||||
log.Println("FlexRadio: Attempting to reconnect...")
|
|
||||||
if err := c.Connect(); err != nil {
|
if err := c.Connect(); err != nil {
|
||||||
log.Printf("FlexRadio: Reconnect failed: %v", err)
|
log.Printf("FlexRadio: Reconnect failed: %v", err)
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
|
|
||||||
// Successfully reconnected
|
|
||||||
c.statusMu.Lock()
|
|
||||||
if c.lastStatus != nil {
|
|
||||||
c.lastStatus.Connected = true
|
|
||||||
}
|
|
||||||
c.statusMu.Unlock()
|
|
||||||
|
|
||||||
// Re-subscribe to slices after reconnection
|
|
||||||
c.subscribeToSlices()
|
|
||||||
|
|
||||||
case <-c.stopChan:
|
|
||||||
log.Println("FlexRadio: Message loop stopping (stop signal received)")
|
|
||||||
return
|
|
||||||
}
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
|
|
||||||
// Read from connection
|
|
||||||
c.connMu.Lock()
|
|
||||||
if c.conn == nil || c.reader == nil {
|
|
||||||
c.connMu.Unlock()
|
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -260,8 +211,6 @@ func (c *Client) messageLoop() {
|
|||||||
c.lastStatus.Connected = false
|
c.lastStatus.Connected = false
|
||||||
}
|
}
|
||||||
c.statusMu.Unlock()
|
c.statusMu.Unlock()
|
||||||
|
|
||||||
log.Println("FlexRadio: Connection lost, will attempt reconnection...")
|
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -2,7 +2,6 @@
|
|||||||
import { onMount, onDestroy } from 'svelte';
|
import { onMount, onDestroy } from 'svelte';
|
||||||
import { wsService, connected, systemStatus } from './lib/websocket.js';
|
import { wsService, connected, systemStatus } from './lib/websocket.js';
|
||||||
import { api } from './lib/api.js';
|
import { api } from './lib/api.js';
|
||||||
import StatusBanner from './components/StatusBanner.svelte';
|
|
||||||
import WebSwitch from './components/WebSwitch.svelte';
|
import WebSwitch from './components/WebSwitch.svelte';
|
||||||
import PowerGenius from './components/PowerGenius.svelte';
|
import PowerGenius from './components/PowerGenius.svelte';
|
||||||
import TunerGenius from './components/TunerGenius.svelte';
|
import TunerGenius from './components/TunerGenius.svelte';
|
||||||
@@ -14,8 +13,6 @@
|
|||||||
let isConnected = false;
|
let isConnected = false;
|
||||||
let currentTime = new Date();
|
let currentTime = new Date();
|
||||||
let callsign = 'F4BPO'; // Default
|
let callsign = 'F4BPO'; // Default
|
||||||
let latitude = null;
|
|
||||||
let longitude = null;
|
|
||||||
|
|
||||||
const unsubscribeStatus = systemStatus.subscribe(value => {
|
const unsubscribeStatus = systemStatus.subscribe(value => {
|
||||||
status = value;
|
status = value;
|
||||||
@@ -43,10 +40,6 @@
|
|||||||
if (config.callsign) {
|
if (config.callsign) {
|
||||||
callsign = config.callsign;
|
callsign = config.callsign;
|
||||||
}
|
}
|
||||||
if (config.location) {
|
|
||||||
latitude = config.location.latitude;
|
|
||||||
longitude = config.location.longitude;
|
|
||||||
}
|
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
console.error('Failed to fetch config:', err);
|
console.error('Failed to fetch config:', err);
|
||||||
}
|
}
|
||||||
@@ -114,16 +107,6 @@
|
|||||||
</div>
|
</div>
|
||||||
</header>
|
</header>
|
||||||
|
|
||||||
<!-- ✅ NOUVEAU : Bandeau de statut avec fréquence FlexRadio et alertes météo -->
|
|
||||||
<StatusBanner
|
|
||||||
flexradio={status?.flexradio}
|
|
||||||
weather={status?.weather}
|
|
||||||
{latitude}
|
|
||||||
{longitude}
|
|
||||||
windWarningThreshold={30}
|
|
||||||
gustWarningThreshold={50}
|
|
||||||
/>
|
|
||||||
|
|
||||||
<main>
|
<main>
|
||||||
<div class="dashboard-grid">
|
<div class="dashboard-grid">
|
||||||
<div class="row">
|
<div class="row">
|
||||||
@@ -149,13 +132,12 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
header {
|
header {
|
||||||
background: linear-gradient(135deg, #1a1a2e 0%, #16213e 50%, #0f3460 100%);
|
background: linear-gradient(135deg, #1e3c72 0%, #2a5298 100%);
|
||||||
padding: 8px 24px;
|
padding: 16px 24px;
|
||||||
display: flex;
|
display: flex;
|
||||||
justify-content: space-between;
|
justify-content: space-between;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
box-shadow: 0 4px 20px rgba(0, 0, 0, 0.4);
|
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.3);
|
||||||
border-bottom: 1px solid rgba(79, 195, 247, 0.2);
|
|
||||||
flex-wrap: wrap;
|
flex-wrap: wrap;
|
||||||
gap: 16px;
|
gap: 16px;
|
||||||
}
|
}
|
||||||
@@ -261,7 +243,6 @@
|
|||||||
.date {
|
.date {
|
||||||
font-size: 12px;
|
font-size: 12px;
|
||||||
color: rgba(255, 255, 255, 0.7);
|
color: rgba(255, 255, 255, 0.7);
|
||||||
padding-top: 0px;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
main {
|
main {
|
||||||
|
|||||||
@@ -73,6 +73,7 @@
|
|||||||
<div class="power-bar-container">
|
<div class="power-bar-container">
|
||||||
<div class="power-bar-bg">
|
<div class="power-bar-bg">
|
||||||
<div class="power-bar-fill" style="width: {powerPercent}%">
|
<div class="power-bar-fill" style="width: {powerPercent}%">
|
||||||
|
<div class="power-bar-glow"></div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -281,6 +282,16 @@
|
|||||||
transition: width 0.3s ease;
|
transition: width 0.3s ease;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.power-bar-glow {
|
||||||
|
position: absolute;
|
||||||
|
top: 0;
|
||||||
|
right: 0;
|
||||||
|
width: 20px;
|
||||||
|
height: 100%;
|
||||||
|
background: linear-gradient(90deg, transparent, rgba(255, 255, 255, 0.5));
|
||||||
|
animation: shimmer 2s infinite;
|
||||||
|
}
|
||||||
|
|
||||||
@keyframes shimmer {
|
@keyframes shimmer {
|
||||||
0% { transform: translateX(-100%); }
|
0% { transform: translateX(-100%); }
|
||||||
100% { transform: translateX(100%); }
|
100% { transform: translateX(100%); }
|
||||||
|
|||||||
@@ -1,631 +0,0 @@
|
|||||||
<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 frequency and mode
|
|
||||||
$: frequency = flexradio?.frequency || 0;
|
|
||||||
$: mode = flexradio?.mode || '';
|
|
||||||
$: txEnabled = flexradio?.tx || false;
|
|
||||||
$: connected = flexradio?.connected || false;
|
|
||||||
|
|
||||||
// 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();
|
|
||||||
}
|
|
||||||
|
|
||||||
// Simplified sun calculation (based on NOAA algorithm)
|
|
||||||
function getSunTimes(date, lat, lon) {
|
|
||||||
const rad = Math.PI / 180;
|
|
||||||
const longitude = -lon;
|
|
||||||
const dayOfYear = getDayOfYear(date);
|
|
||||||
|
|
||||||
// Fractional year
|
|
||||||
const gamma = (2 * Math.PI / 365) * (dayOfYear - 1 + (date.getUTCHours() - 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 * (longitude + ha) - eqTime;
|
|
||||||
const sunsetMinutes = 720 - 4 * (longitude - 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);
|
|
||||||
|
|
||||||
console.log(
|
|
||||||
sunrise.toISOString(),
|
|
||||||
sunrise.toString()
|
|
||||||
);
|
|
||||||
|
|
||||||
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);
|
|
||||||
</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 connected && frequency > 0}
|
|
||||||
<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}
|
|
||||||
|
|
||||||
{#if txEnabled}
|
|
||||||
<span class="tx-indicator">
|
|
||||||
TX
|
|
||||||
</span>
|
|
||||||
{/if}
|
|
||||||
{:else}
|
|
||||||
<span class="no-signal">FlexRadio non connecté</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">Météo 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; }
|
|
||||||
}
|
|
||||||
|
|
||||||
.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>
|
|
||||||
@@ -67,6 +67,7 @@
|
|||||||
<div class="power-bar-container">
|
<div class="power-bar-container">
|
||||||
<div class="power-bar-bg">
|
<div class="power-bar-bg">
|
||||||
<div class="power-bar-fill" style="width: {powerPercent}%">
|
<div class="power-bar-fill" style="width: {powerPercent}%">
|
||||||
|
<div class="power-bar-glow"></div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -264,6 +265,16 @@
|
|||||||
transition: width 0.3s ease;
|
transition: width 0.3s ease;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.power-bar-glow {
|
||||||
|
position: absolute;
|
||||||
|
top: 0;
|
||||||
|
right: 0;
|
||||||
|
width: 20px;
|
||||||
|
height: 100%;
|
||||||
|
background: linear-gradient(90deg, transparent, rgba(255, 255, 255, 0.5));
|
||||||
|
animation: shimmer 2s infinite;
|
||||||
|
}
|
||||||
|
|
||||||
@keyframes shimmer {
|
@keyframes shimmer {
|
||||||
0% { transform: translateX(-100%); }
|
0% { transform: translateX(-100%); }
|
||||||
100% { transform: translateX(100%); }
|
100% { transform: translateX(100%); }
|
||||||
|
|||||||
Reference in New Issue
Block a user