Compare commits
11 Commits
2bec98a080
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
| 130efeee83 | |||
| 4eeec6bdf6 | |||
| de3fda2648 | |||
| c6ceeb103b | |||
| b8884d89e3 | |||
| 5332ab9dc1 | |||
| b8db847343 | |||
| 0cb83157de | |||
| 4f484b0091 | |||
| 6b5508802a | |||
| 51e08d9463 |
5
cmd/server/web/dist/index.html
vendored
5
cmd/server/web/dist/index.html
vendored
@@ -7,10 +7,11 @@
|
|||||||
<link rel="preconnect" href="https://fonts.googleapis.com">
|
<link rel="preconnect" href="https://fonts.googleapis.com">
|
||||||
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
|
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
|
||||||
<link href="https://fonts.googleapis.com/css2?family=Roboto:wght@300;400;500;700&display=swap" rel="stylesheet">
|
<link href="https://fonts.googleapis.com/css2?family=Roboto:wght@300;400;500;700&display=swap" rel="stylesheet">
|
||||||
<script type="module" crossorigin src="/assets/index-ghAyyhf_.js"></script>
|
<script type="module" crossorigin src="/assets/index-BqlArLJ0.js"></script>
|
||||||
<link rel="stylesheet" crossorigin href="/assets/index-oYZfaWiS.css">
|
<link rel="stylesheet" crossorigin href="/assets/index-Bl7hatTL.css">
|
||||||
</head>
|
</head>
|
||||||
<body>
|
<body>
|
||||||
<div id="app"></div>
|
<div id="app"></div>
|
||||||
|
|
||||||
</body>
|
</body>
|
||||||
</html>
|
</html>
|
||||||
@@ -119,13 +119,32 @@ func (dm *DeviceManager) Initialize() error {
|
|||||||
dm.flexRadio = flexradio.New(
|
dm.flexRadio = flexradio.New(
|
||||||
dm.config.Devices.FlexRadio.Host,
|
dm.config.Devices.FlexRadio.Host,
|
||||||
dm.config.Devices.FlexRadio.Port,
|
dm.config.Devices.FlexRadio.Port,
|
||||||
dm.config.Devices.FlexRadio.InterlockName,
|
|
||||||
)
|
)
|
||||||
|
|
||||||
// Set callback for immediate frequency changes (no waiting for update cycle)
|
// Set callback for immediate frequency changes (no waiting for update cycle)
|
||||||
dm.flexRadio.SetFrequencyChangeCallback(func(freqMHz float64) {
|
dm.flexRadio.SetFrequencyChangeCallback(func(freqMHz float64) {
|
||||||
dm.handleFrequencyChange(freqMHz)
|
dm.handleFrequencyChange(freqMHz)
|
||||||
})
|
})
|
||||||
|
|
||||||
|
// Set callback to check if transmit is allowed (based on Ultrabeam motors)
|
||||||
|
dm.flexRadio.SetTransmitCheckCallback(func() bool {
|
||||||
|
// Get current Ultrabeam status
|
||||||
|
ubStatus, err := dm.ultrabeam.GetStatus()
|
||||||
|
if err != nil || ubStatus == nil {
|
||||||
|
// If we cannot get status, allow transmit (fail-safe)
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
|
// Block transmit if motors are moving
|
||||||
|
motorsMoving := ubStatus.MotorsMoving != 0
|
||||||
|
if motorsMoving {
|
||||||
|
log.Printf("FlexRadio PTT check: Motors moving (bitmask=%d) - BLOCKING", ubStatus.MotorsMoving)
|
||||||
|
} else {
|
||||||
|
log.Printf("FlexRadio PTT check: Motors stopped - ALLOWING")
|
||||||
|
}
|
||||||
|
|
||||||
|
return !motorsMoving
|
||||||
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
// Initialize Solar data client
|
// Initialize Solar data client
|
||||||
|
|||||||
@@ -31,9 +31,10 @@ type Client struct {
|
|||||||
|
|
||||||
// Callbacks
|
// Callbacks
|
||||||
onFrequencyChange func(freqMHz float64)
|
onFrequencyChange func(freqMHz float64)
|
||||||
|
checkTransmitAllowed func() bool // Returns true if transmit allowed (motors not moving)
|
||||||
}
|
}
|
||||||
|
|
||||||
func New(host string, port int, interlockName string) *Client {
|
func New(host string, port int) *Client {
|
||||||
return &Client{
|
return &Client{
|
||||||
host: host,
|
host: host,
|
||||||
port: port,
|
port: port,
|
||||||
@@ -49,6 +50,11 @@ func (c *Client) SetFrequencyChangeCallback(callback func(freqMHz float64)) {
|
|||||||
c.onFrequencyChange = callback
|
c.onFrequencyChange = callback
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// SetTransmitCheckCallback sets the callback to check if transmit is allowed
|
||||||
|
func (c *Client) SetTransmitCheckCallback(callback func() bool) {
|
||||||
|
c.checkTransmitAllowed = callback
|
||||||
|
}
|
||||||
|
|
||||||
func (c *Client) Connect() error {
|
func (c *Client) Connect() error {
|
||||||
c.connMu.Lock()
|
c.connMu.Lock()
|
||||||
defer c.connMu.Unlock()
|
defer c.connMu.Unlock()
|
||||||
@@ -222,6 +228,7 @@ func (c *Client) messageLoop() {
|
|||||||
func (c *Client) handleMessage(msg string) {
|
func (c *Client) handleMessage(msg string) {
|
||||||
// Response format: R<seq>|<status>|<data>
|
// Response format: R<seq>|<status>|<data>
|
||||||
if strings.HasPrefix(msg, "R") {
|
if strings.HasPrefix(msg, "R") {
|
||||||
|
c.handleResponse(msg)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -244,6 +251,21 @@ func (c *Client) handleMessage(msg string) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func (c *Client) handleResponse(msg string) {
|
||||||
|
// Format: R<seq>|<status>|<data>
|
||||||
|
// Example: R21|0|000000F4
|
||||||
|
parts := strings.SplitN(msg, "|", 3)
|
||||||
|
if len(parts) < 2 {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
status := parts[1]
|
||||||
|
if status != "0" {
|
||||||
|
log.Printf("FlexRadio: Command error: status=%s, message=%s", status, msg)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
func (c *Client) handleStatus(msg string) {
|
func (c *Client) handleStatus(msg string) {
|
||||||
// Format: S<handle>|<key>=<value> ...
|
// Format: S<handle>|<key>=<value> ...
|
||||||
parts := strings.SplitN(msg, "|", 2)
|
parts := strings.SplitN(msg, "|", 2)
|
||||||
|
|||||||
@@ -24,6 +24,7 @@ type Client struct {
|
|||||||
|
|
||||||
type Status struct {
|
type Status struct {
|
||||||
Heading int `json:"heading"`
|
Heading int `json:"heading"`
|
||||||
|
TargetHeading int `json:"target_heading"`
|
||||||
Connected bool `json:"connected"`
|
Connected bool `json:"connected"`
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -211,6 +212,11 @@ func (c *Client) parseStatus(response string) *Status {
|
|||||||
if err == nil {
|
if err == nil {
|
||||||
status.Heading = heading
|
status.Heading = heading
|
||||||
}
|
}
|
||||||
|
targetStr := response[19:22]
|
||||||
|
targetHeading, err := strconv.Atoi(strings.TrimSpace(targetStr))
|
||||||
|
if err == nil {
|
||||||
|
status.TargetHeading = targetHeading
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
return status
|
return status
|
||||||
|
|||||||
@@ -2,6 +2,7 @@
|
|||||||
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';
|
||||||
@@ -13,6 +14,8 @@
|
|||||||
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;
|
||||||
@@ -40,6 +43,10 @@
|
|||||||
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);
|
||||||
}
|
}
|
||||||
@@ -95,8 +102,8 @@
|
|||||||
|
|
||||||
<div class="header-right">
|
<div class="header-right">
|
||||||
<div class="weather-info">
|
<div class="weather-info">
|
||||||
<span title="Wind">🌬️ {weatherData.wind_speed.toFixed(1)}m/s</span>
|
<span title="Wind">🌬️ {weatherData.wind_speed.toFixed(1)} km/h</span>
|
||||||
<span title="Gust">💨 {weatherData.wind_gust.toFixed(1)}m/s</span>
|
<span title="Gust">💨 {weatherData.wind_gust.toFixed(1)} km/h</span>
|
||||||
<span title="Temperature">🌡️ {weatherData.temp.toFixed(1)} °C</span>
|
<span title="Temperature">🌡️ {weatherData.temp.toFixed(1)} °C</span>
|
||||||
<span title="Feels like">→ {weatherData.feels_like.toFixed(1)} °C</span>
|
<span title="Feels like">→ {weatherData.feels_like.toFixed(1)} °C</span>
|
||||||
</div>
|
</div>
|
||||||
@@ -107,6 +114,16 @@
|
|||||||
</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">
|
||||||
@@ -132,12 +149,13 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
header {
|
header {
|
||||||
background: linear-gradient(135deg, #1e3c72 0%, #2a5298 100%);
|
background: linear-gradient(135deg, #1a1a2e 0%, #16213e 50%, #0f3460 100%);
|
||||||
padding: 16px 24px;
|
padding: 8px 24px;
|
||||||
display: flex;
|
display: flex;
|
||||||
justify-content: space-between;
|
justify-content: space-between;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.3);
|
box-shadow: 0 4px 20px rgba(0, 0, 0, 0.4);
|
||||||
|
border-bottom: 1px solid rgba(79, 195, 247, 0.2);
|
||||||
flex-wrap: wrap;
|
flex-wrap: wrap;
|
||||||
gap: 16px;
|
gap: 16px;
|
||||||
}
|
}
|
||||||
@@ -243,6 +261,7 @@
|
|||||||
.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,7 +73,6 @@
|
|||||||
<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>
|
||||||
@@ -282,16 +281,6 @@
|
|||||||
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%); }
|
||||||
|
|||||||
@@ -13,7 +13,6 @@
|
|||||||
// Update heading with detailed logging to debug
|
// Update heading with detailed logging to debug
|
||||||
$: if (status?.heading !== undefined && status?.heading !== null) {
|
$: if (status?.heading !== undefined && status?.heading !== null) {
|
||||||
const newHeading = status.heading;
|
const newHeading = status.heading;
|
||||||
const oldHeading = heading;
|
|
||||||
|
|
||||||
if (heading === null) {
|
if (heading === null) {
|
||||||
// First time: accept any value
|
// First time: accept any value
|
||||||
@@ -25,7 +24,6 @@
|
|||||||
} else {
|
} else {
|
||||||
// Normal update
|
// Normal update
|
||||||
heading = newHeading;
|
heading = newHeading;
|
||||||
console.log(` ✓ Updated to ${heading}°`);
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -34,32 +32,38 @@
|
|||||||
|
|
||||||
$: connected = status?.connected || false;
|
$: connected = status?.connected || false;
|
||||||
|
|
||||||
let targetHeading = 0;
|
// ✅ Target heading from rotator status (when controlled by PST Rotator or other software)
|
||||||
let hasTarget = false;
|
$: statusTargetHeading = status?.target_heading ?? null;
|
||||||
|
|
||||||
// Clear target when we reach it (within 5 degrees)
|
// Local target (when clicking on map in ShackMaster)
|
||||||
$: if (hasTarget && heading !== null) {
|
let localTargetHeading = null;
|
||||||
const diff = Math.abs(heading - targetHeading);
|
|
||||||
|
// ✅ Determine if antenna is moving to a target from status
|
||||||
|
// (target differs from current heading by more than 2 degrees)
|
||||||
|
$: isMovingFromStatus = statusTargetHeading !== null &&
|
||||||
|
heading !== null &&
|
||||||
|
(() => {
|
||||||
|
const diff = Math.abs(statusTargetHeading - heading);
|
||||||
const wrappedDiff = Math.min(diff, 360 - diff);
|
const wrappedDiff = Math.min(diff, 360 - diff);
|
||||||
if (wrappedDiff < 5) {
|
return wrappedDiff > 2;
|
||||||
hasTarget = false;
|
})();
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
async function goToHeading() {
|
// ✅ Active target: prefer status target when moving, otherwise use local target
|
||||||
if (targetHeading < 0 || targetHeading > 359) {
|
$: activeTargetHeading = localTargetHeading ?? (isMovingFromStatus ? statusTargetHeading : null);
|
||||||
// Removed alert popup - check console for errors
|
|
||||||
return;
|
// ✅ Has target if there's an active target that differs from current heading
|
||||||
}
|
$: hasTarget = activeTargetHeading !== null && heading !== null && (() => {
|
||||||
try {
|
const diff = Math.abs(activeTargetHeading - heading);
|
||||||
hasTarget = true; // Mark that we have a target
|
const wrappedDiff = Math.min(diff, 360 - diff);
|
||||||
// Subtract 10 degrees to compensate for rotator momentum
|
return wrappedDiff > 2;
|
||||||
const adjustedHeading = (targetHeading + 360) % 360;
|
})();
|
||||||
await api.rotator.setHeading(adjustedHeading);
|
|
||||||
} catch (err) {
|
// Clear local target when we reach it (within 3 degrees)
|
||||||
console.error('Failed to set heading:', err);
|
$: if (localTargetHeading !== null && heading !== null) {
|
||||||
hasTarget = false;
|
const diff = Math.abs(heading - localTargetHeading);
|
||||||
// Removed alert popup - check console for errors
|
const wrappedDiff = Math.min(diff, 360 - diff);
|
||||||
|
if (wrappedDiff < 3) {
|
||||||
|
localTargetHeading = null;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -81,6 +85,7 @@
|
|||||||
|
|
||||||
async function stop() {
|
async function stop() {
|
||||||
try {
|
try {
|
||||||
|
localTargetHeading = null; // Clear local target on stop
|
||||||
await api.rotator.stop();
|
await api.rotator.stop();
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
console.error('Failed to stop:', err);
|
console.error('Failed to stop:', err);
|
||||||
@@ -88,7 +93,7 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Handle click on compass to set heading
|
// Handle click on compass to set heading
|
||||||
function handleCompassClick(event) {
|
async function handleCompassClick(event) {
|
||||||
const svg = event.currentTarget;
|
const svg = event.currentTarget;
|
||||||
const rect = svg.getBoundingClientRect();
|
const rect = svg.getBoundingClientRect();
|
||||||
const centerX = rect.width / 2;
|
const centerX = rect.width / 2;
|
||||||
@@ -104,10 +109,16 @@
|
|||||||
|
|
||||||
// Round to nearest 5 degrees
|
// Round to nearest 5 degrees
|
||||||
const roundedHeading = Math.round(angle / 5) * 5;
|
const roundedHeading = Math.round(angle / 5) * 5;
|
||||||
|
const adjustedHeading = (roundedHeading + 360) % 360;
|
||||||
|
|
||||||
// Set target and go
|
// ✅ CORRIGÉ : Send command first, then set localTargetHeading only on success
|
||||||
targetHeading = roundedHeading;
|
try {
|
||||||
goToHeading();
|
await api.rotator.setHeading(adjustedHeading);
|
||||||
|
// Only set local target AFTER successful API call
|
||||||
|
localTargetHeading = adjustedHeading;
|
||||||
|
} catch (err) {
|
||||||
|
console.error('Failed to set heading:', err);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
@@ -124,8 +135,8 @@
|
|||||||
<div class="heading-label">CURRENT HEADING</div>
|
<div class="heading-label">CURRENT HEADING</div>
|
||||||
<div class="heading-value">
|
<div class="heading-value">
|
||||||
{displayHeading}°
|
{displayHeading}°
|
||||||
{#if hasTarget}
|
{#if hasTarget && activeTargetHeading !== null}
|
||||||
<span class="target-indicator">→ {targetHeading}°</span>
|
<span class="target-indicator">→ {activeTargetHeading}°</span>
|
||||||
{/if}
|
{/if}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -282,19 +293,21 @@
|
|||||||
|
|
||||||
</g>
|
</g>
|
||||||
|
|
||||||
<!-- Target arrow (if we have a target) -->
|
<!-- ✅ Target arrow (yellow) - shown when antenna is moving to target -->
|
||||||
{#if hasTarget}
|
{#if hasTarget && activeTargetHeading !== null}
|
||||||
<g transform="rotate({targetHeading})">
|
<g transform="rotate({activeTargetHeading})">
|
||||||
<line x1="0" y1="0" x2="0" y2="-120"
|
<!-- Target direction line (dashed yellow) -->
|
||||||
|
<line x1="0" y1="0" x2="0" y2="-135"
|
||||||
stroke="#ffc107"
|
stroke="#ffc107"
|
||||||
stroke-width="3"
|
stroke-width="3"
|
||||||
stroke-dasharray="8,4"
|
stroke-dasharray="8,4"
|
||||||
opacity="0.9"/>
|
opacity="0.9"/>
|
||||||
<g transform="translate(0, -120)">
|
<!-- Target arrow head with pulse animation -->
|
||||||
<polygon points="0,-15 -10,10 0,5 10,10"
|
<g transform="translate(0, -135)">
|
||||||
|
<polygon points="0,-12 -8,6 0,2 8,6"
|
||||||
fill="#ffc107"
|
fill="#ffc107"
|
||||||
stroke="#ff9800"
|
stroke="#ff9800"
|
||||||
stroke-width="2"
|
stroke-width="1.5"
|
||||||
style="filter: drop-shadow(0 0 10px rgba(255, 193, 7, 0.8))">
|
style="filter: drop-shadow(0 0 10px rgba(255, 193, 7, 0.8))">
|
||||||
<animate attributeName="opacity" values="0.8;1;0.8" dur="1s" repeatCount="indefinite"/>
|
<animate attributeName="opacity" values="0.8;1;0.8" dur="1s" repeatCount="indefinite"/>
|
||||||
</polygon>
|
</polygon>
|
||||||
@@ -302,7 +315,7 @@
|
|||||||
</g>
|
</g>
|
||||||
{/if}
|
{/if}
|
||||||
|
|
||||||
<!-- Center dot (your QTH - JN36dg) -->
|
<!-- Center dot (your QTH) -->
|
||||||
<circle cx="0" cy="0" r="5" fill="#f44336" stroke="#fff" stroke-width="2">
|
<circle cx="0" cy="0" r="5" fill="#f44336" stroke="#fff" stroke-width="2">
|
||||||
<animate attributeName="r" values="5;7;5" dur="2s" repeatCount="indefinite"/>
|
<animate attributeName="r" values="5;7;5" dur="2s" repeatCount="indefinite"/>
|
||||||
</circle>
|
</circle>
|
||||||
@@ -344,8 +357,6 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
{/if}
|
{/if}
|
||||||
|
|
||||||
<!-- Go To Heading -->
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -396,8 +407,6 @@
|
|||||||
gap: 10px;
|
gap: 10px;
|
||||||
}
|
}
|
||||||
|
|
||||||
/* Heading Display */
|
|
||||||
|
|
||||||
.heading-controls-row {
|
.heading-controls-row {
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
@@ -486,7 +495,6 @@
|
|||||||
50% { opacity: 1; }
|
50% { opacity: 1; }
|
||||||
}
|
}
|
||||||
|
|
||||||
/* Map */
|
|
||||||
.map-container {
|
.map-container {
|
||||||
display: flex;
|
display: flex;
|
||||||
justify-content: center;
|
justify-content: center;
|
||||||
@@ -522,7 +530,7 @@
|
|||||||
.clickable-compass {
|
.clickable-compass {
|
||||||
cursor: crosshair;
|
cursor: crosshair;
|
||||||
user-select: none;
|
user-select: none;
|
||||||
outline: none; /* Remove focus outline */
|
outline: none;
|
||||||
}
|
}
|
||||||
|
|
||||||
.clickable-compass:hover {
|
.clickable-compass:hover {
|
||||||
@@ -541,5 +549,4 @@
|
|||||||
font-size: 12px;
|
font-size: 12px;
|
||||||
font-weight: 600;
|
font-weight: 600;
|
||||||
}
|
}
|
||||||
|
|
||||||
</style>
|
</style>
|
||||||
625
web/src/components/StatusBanner.svelte
Normal file
625
web/src/components/StatusBanner.svelte
Normal file
@@ -0,0 +1,625 @@
|
|||||||
|
<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 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);
|
||||||
|
</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 non configurée</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,7 +67,6 @@
|
|||||||
<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>
|
||||||
@@ -265,16 +264,6 @@
|
|||||||
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%); }
|
||||||
|
|||||||
@@ -19,6 +19,16 @@
|
|||||||
$: interlockState = flexradio?.interlock_state || null;
|
$: interlockState = flexradio?.interlock_state || null;
|
||||||
$: interlockColor = getInterlockColor(interlockState);
|
$: interlockColor = getInterlockColor(interlockState);
|
||||||
|
|
||||||
|
// Debug log
|
||||||
|
$: if (flexradio) {
|
||||||
|
console.log('FlexRadio data:', {
|
||||||
|
connected: flexradio.connected,
|
||||||
|
interlock_state: flexradio.interlock_state,
|
||||||
|
interlockConnected,
|
||||||
|
interlockState
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
function getInterlockColor(state) {
|
function getInterlockColor(state) {
|
||||||
switch(state) {
|
switch(state) {
|
||||||
case 'READY': return '#4caf50';
|
case 'READY': return '#4caf50';
|
||||||
@@ -154,14 +164,6 @@
|
|||||||
<div class="card-header">
|
<div class="card-header">
|
||||||
<h2>Ultrabeam VL2.3</h2>
|
<h2>Ultrabeam VL2.3</h2>
|
||||||
<div class="header-right">
|
<div class="header-right">
|
||||||
{#if interlockConnected && interlockState}
|
|
||||||
<div class="interlock-badge" style="border-color: {interlockColor}; color: {interlockColor}">
|
|
||||||
{interlockState === 'READY' ? '🔓 TX OK' :
|
|
||||||
interlockState === 'NOT_READY' ? '🔒 TX Block' :
|
|
||||||
interlockState === 'PTT_REQUESTED' ? '⏳ PTT' :
|
|
||||||
interlockState === 'TRANSMITTING' ? '📡 TX' : '❓'}
|
|
||||||
</div>
|
|
||||||
{/if}
|
|
||||||
<span class="status-dot" class:disconnected={!connected}></span>
|
<span class="status-dot" class:disconnected={!connected}></span>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -191,7 +193,7 @@
|
|||||||
<div class="auto-track-controls">
|
<div class="auto-track-controls">
|
||||||
<label class="toggle-label">
|
<label class="toggle-label">
|
||||||
<input type="checkbox" bind:checked={autoTrackEnabled} on:change={updateAutoTrack} />
|
<input type="checkbox" bind:checked={autoTrackEnabled} on:change={updateAutoTrack} />
|
||||||
<span>Enable Auto-Track from Radio</span>
|
<span>Enable Auto-Track from Tuner</span>
|
||||||
</label>
|
</label>
|
||||||
|
|
||||||
<div class="threshold-group">
|
<div class="threshold-group">
|
||||||
@@ -343,18 +345,6 @@
|
|||||||
gap: 12px;
|
gap: 12px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.interlock-badge {
|
|
||||||
padding: 4px 10px;
|
|
||||||
border-radius: 12px;
|
|
||||||
border: 2px solid;
|
|
||||||
font-size: 11px;
|
|
||||||
font-weight: 700;
|
|
||||||
text-transform: uppercase;
|
|
||||||
letter-spacing: 0.5px;
|
|
||||||
background: rgba(0, 0, 0, 0.3);
|
|
||||||
transition: all 0.2s;
|
|
||||||
}
|
|
||||||
|
|
||||||
h2 {
|
h2 {
|
||||||
margin: 0;
|
margin: 0;
|
||||||
font-size: 20px;
|
font-size: 20px;
|
||||||
|
|||||||
Reference in New Issue
Block a user