Files
ShackMaster/web/src/PopupApp.svelte
2026-04-04 12:52:08 +02:00

449 lines
15 KiB
Svelte

<script>
import { onMount, onDestroy } from 'svelte';
import { api } from './lib/api.js';
import { wsService, connected, systemStatus } from './lib/websocket.js';
let status = null;
let isConnected = false;
const unsubStatus = systemStatus.subscribe(v => { status = v; });
const unsubConn = connected.subscribe(v => { isConnected = v; });
// Rotator state
let heading = null;
let localTargetHeading = null;
$: rotator = status?.rotator_genius;
$: ultrabeam = status?.ultrabeam;
$: ultrabeamDirection = ultrabeam?.direction ?? 0;
$: if (rotator?.heading !== undefined && rotator?.heading !== null) {
const newHeading = rotator.heading;
if (heading === null) {
heading = newHeading;
} else if (newHeading === 0 && heading > 10 && heading < 350) {
// ignore glitch
} else {
heading = newHeading;
}
}
$: displayHeading = heading !== null ? heading : 0;
$: connected2 = rotator?.connected || false;
$: statusTargetHeading = rotator?.target_heading ?? null;
$: isMovingFromStatus = statusTargetHeading !== null &&
heading !== null &&
(() => {
const diff = Math.abs(statusTargetHeading - heading);
return Math.min(diff, 360 - diff) > 2;
})();
$: activeTargetHeading = localTargetHeading ?? (isMovingFromStatus ? statusTargetHeading : null);
$: hasTarget = activeTargetHeading !== null && heading !== null && (() => {
const diff = Math.abs(activeTargetHeading - heading);
return Math.min(diff, 360 - diff) > 2;
})();
$: if (localTargetHeading !== null && heading !== null) {
const diff = Math.abs(heading - localTargetHeading);
if (Math.min(diff, 360 - diff) < 3) {
localTargetHeading = null;
}
}
// Ultrabeam direction state (local copy for immediate UI feedback)
let targetDirection = 0;
$: targetDirection = ultrabeamDirection;
onMount(() => {
wsService.connect();
});
onDestroy(() => {
wsService.disconnect();
unsubStatus();
unsubConn();
});
async function rotateCW() {
try { await api.rotator.rotateCW(); } catch (e) {}
}
async function rotateCCW() {
try { await api.rotator.rotateCCW(); } catch (e) {}
}
async function stop() {
localTargetHeading = null;
try { await api.rotator.stop(); } catch (e) {}
}
async function handleCompassClick(event) {
const svg = event.currentTarget;
const rect = svg.getBoundingClientRect();
const x = event.clientX - rect.left - rect.width / 2;
const y = event.clientY - rect.top - rect.height / 2;
let angle = Math.atan2(x, -y) * (180 / Math.PI);
if (angle < 0) angle += 360;
const adjusted = (Math.round(angle / 5) * 5 + 360) % 360;
try {
await api.rotator.setHeading(adjusted);
localTargetHeading = adjusted;
} catch (e) {}
}
async function setDirection(dir) {
targetDirection = dir;
try {
const freq = ultrabeam?.frequency || 0;
if (freq > 0) {
await api.ultrabeam.setFrequency(freq, dir);
}
await api.ultrabeam.setDirection(dir);
} catch (e) {}
}
</script>
<div class="popup-root">
<!-- Header -->
<div class="header">
<div class="header-left">
<span class="status-dot" class:disconnected={!connected2}></span>
<span class="title">Rotator Control</span>
</div>
<div class="heading-value">
{displayHeading}°
{#if hasTarget && activeTargetHeading !== null}
<span class="target-indicator">{activeTargetHeading}°</span>
{/if}
</div>
<div class="controls-compact">
<button class="btn-mini ccw" on:click={rotateCCW} title="Rotate CCW"></button>
<button class="btn-mini stop-btn" on:click={stop} title="Stop"></button>
<button class="btn-mini cw" on:click={rotateCW} title="Rotate CW"></button>
</div>
</div>
<!-- Compass Map -->
<div class="map-container">
<svg viewBox="0 0 300 300" class="map-svg"
on:click={handleCompassClick}
on:keydown={(e) => e.key === 'Enter' && handleCompassClick(e)}
role="button"
tabindex="0"
aria-label="Click to rotate antenna">
<defs>
<radialGradient id="beamGrad">
<stop offset="0%" style="stop-color:rgba(79,195,247,0.7);stop-opacity:1" />
<stop offset="100%" style="stop-color:rgba(79,195,247,0);stop-opacity:0" />
</radialGradient>
</defs>
<circle cx="150" cy="150" r="140" fill="rgba(30,64,175,0.15)" stroke="rgba(79,195,247,0.4)" stroke-width="2"/>
<circle cx="150" cy="150" r="105" fill="none" stroke="rgba(79,195,247,0.2)" stroke-width="1" stroke-dasharray="3,3"/>
<circle cx="150" cy="150" r="70" fill="none" stroke="rgba(79,195,247,0.2)" stroke-width="1" stroke-dasharray="3,3"/>
<circle cx="150" cy="150" r="35" fill="none" stroke="rgba(79,195,247,0.2)" stroke-width="1" stroke-dasharray="3,3"/>
<g transform="translate(150,150)">
<!-- Physical antenna indicator (180° / Bi-Dir) -->
{#if ultrabeamDirection === 1 || ultrabeamDirection === 2}
<g transform="rotate({displayHeading})">
<line x1="0" y1="0" x2="0" y2="-125"
stroke="rgba(255,255,255,0.3)" stroke-width="2" stroke-dasharray="5,5" opacity="0.6"/>
<g transform="translate(0,-125)">
<polygon points="0,-8 -5,5 5,5"
fill="rgba(255,255,255,0.4)" stroke="rgba(255,255,255,0.5)" stroke-width="1"/>
</g>
</g>
{/if}
<g transform="rotate({displayHeading})">
{#if ultrabeamDirection === 0}
<path d="M 0,0 L {-Math.sin(15*Math.PI/180)*130},{-Math.cos(15*Math.PI/180)*130}
A 130,130 0 0,1 {Math.sin(15*Math.PI/180)*130},{-Math.cos(15*Math.PI/180)*130} Z"
fill="url(#beamGrad)" opacity="0.85"/>
<line x1="0" y1="0" x2={-Math.sin(15*Math.PI/180)*130} y2={-Math.cos(15*Math.PI/180)*130}
stroke="#4fc3f7" stroke-width="2" opacity="0.9"/>
<line x1="0" y1="0" x2={Math.sin(15*Math.PI/180)*130} y2={-Math.cos(15*Math.PI/180)*130}
stroke="#4fc3f7" stroke-width="2" opacity="0.9"/>
<g transform="translate(0,-110)">
<polygon points="0,-20 -8,5 0,0 8,5" fill="#4fc3f7" stroke="#0288d1" stroke-width="2"
style="filter:drop-shadow(0 0 10px rgba(79,195,247,1))"/>
</g>
{/if}
{#if ultrabeamDirection === 1}
<path d="M 0,0 L {-Math.sin(15*Math.PI/180)*130},{Math.cos(15*Math.PI/180)*130}
A 130,130 0 0,0 {Math.sin(15*Math.PI/180)*130},{Math.cos(15*Math.PI/180)*130} Z"
fill="url(#beamGrad)" opacity="0.85"/>
<line x1="0" y1="0" x2={-Math.sin(15*Math.PI/180)*130} y2={Math.cos(15*Math.PI/180)*130}
stroke="#4fc3f7" stroke-width="2" opacity="0.9"/>
<line x1="0" y1="0" x2={Math.sin(15*Math.PI/180)*130} y2={Math.cos(15*Math.PI/180)*130}
stroke="#4fc3f7" stroke-width="2" opacity="0.9"/>
<g transform="translate(0,110)">
<polygon points="0,20 -8,-5 0,0 8,-5" fill="#4fc3f7" stroke="#0288d1" stroke-width="2"
style="filter:drop-shadow(0 0 10px rgba(79,195,247,1))"/>
</g>
{/if}
{#if ultrabeamDirection === 2}
<path d="M 0,0 L {-Math.sin(15*Math.PI/180)*130},{-Math.cos(15*Math.PI/180)*130}
A 130,130 0 0,1 {Math.sin(15*Math.PI/180)*130},{-Math.cos(15*Math.PI/180)*130} Z"
fill="url(#beamGrad)" opacity="0.7"/>
<path d="M 0,0 L {-Math.sin(15*Math.PI/180)*130},{Math.cos(15*Math.PI/180)*130}
A 130,130 0 0,0 {Math.sin(15*Math.PI/180)*130},{Math.cos(15*Math.PI/180)*130} Z"
fill="url(#beamGrad)" opacity="0.7"/>
<line x1="0" y1="0" x2={-Math.sin(15*Math.PI/180)*130} y2={-Math.cos(15*Math.PI/180)*130}
stroke="#4fc3f7" stroke-width="2" opacity="0.8"/>
<line x1="0" y1="0" x2={Math.sin(15*Math.PI/180)*130} y2={-Math.cos(15*Math.PI/180)*130}
stroke="#4fc3f7" stroke-width="2" opacity="0.8"/>
<line x1="0" y1="0" x2={-Math.sin(15*Math.PI/180)*130} y2={Math.cos(15*Math.PI/180)*130}
stroke="#4fc3f7" stroke-width="2" opacity="0.8"/>
<line x1="0" y1="0" x2={Math.sin(15*Math.PI/180)*130} y2={Math.cos(15*Math.PI/180)*130}
stroke="#4fc3f7" stroke-width="2" opacity="0.8"/>
<g transform="translate(0,-110)">
<polygon points="0,-20 -8,5 0,0 8,5" fill="#4fc3f7" stroke="#0288d1" stroke-width="2"
style="filter:drop-shadow(0 0 10px rgba(79,195,247,1))"/>
</g>
<g transform="translate(0,110)">
<polygon points="0,20 -8,-5 0,0 8,-5" fill="#4fc3f7" stroke="#0288d1" stroke-width="2"
style="filter:drop-shadow(0 0 10px rgba(79,195,247,1))"/>
</g>
{/if}
</g>
<!-- Target arrow -->
{#if hasTarget && activeTargetHeading !== null}
<g transform="rotate({activeTargetHeading})">
<line x1="0" y1="0" x2="0" y2="-135"
stroke="#ffc107" stroke-width="3" stroke-dasharray="8,4" opacity="0.9"/>
<g transform="translate(0,-135)">
<polygon points="0,-12 -8,6 0,2 8,6"
fill="#ffc107" stroke="#ff9800" stroke-width="1.5"
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"/>
</polygon>
</g>
</g>
{/if}
<!-- QTH dot -->
<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"/>
</circle>
<circle cx="0" cy="0" r="10" fill="none" stroke="#f44336" stroke-width="1.5" opacity="0.5">
<animate attributeName="r" values="10;16;10" dur="2s" repeatCount="indefinite"/>
<animate attributeName="opacity" values="0.5;0;0.5" dur="2s" repeatCount="indefinite"/>
</circle>
</g>
<!-- Cardinals -->
<text x="150" y="20" text-anchor="middle" class="cardinal">N</text>
<text x="280" y="155" text-anchor="middle" class="cardinal">E</text>
<text x="150" y="285" text-anchor="middle" class="cardinal">S</text>
<text x="20" y="155" text-anchor="middle" class="cardinal">W</text>
{#each [45,135,225,315] as angle}
{@const x = 150 + 125 * Math.sin(angle * Math.PI / 180)}
{@const y = 150 - 125 * Math.cos(angle * Math.PI / 180)}
<text {x} {y} text-anchor="middle" dominant-baseline="middle" class="degree-label">{angle}°</text>
{/each}
</svg>
</div>
<!-- Ultrabeam Direction Buttons -->
<div class="dir-row">
<button class="dir-btn" class:active={targetDirection === 0} on:click={() => setDirection(0)}>Normal</button>
<button class="dir-btn" class:active={targetDirection === 1} on:click={() => setDirection(1)}>180°</button>
<button class="dir-btn" class:active={targetDirection === 2} on:click={() => setDirection(2)}>Bi-Dir</button>
</div>
</div>
<style>
:global(html, body) {
background: #0f1923;
color: #e0e0e0;
font-family: 'Roboto', sans-serif;
margin: 0;
padding: 0;
overflow: hidden;
height: 100%;
}
.popup-root {
display: flex;
flex-direction: column;
height: 100vh;
background: linear-gradient(135deg, #1a2332 0%, #0f1923 100%);
user-select: none;
}
.header {
display: flex;
align-items: center;
justify-content: space-between;
padding: 6px 10px;
background: rgba(79,195,247,0.05);
border-bottom: 1px solid #2d3748;
gap: 8px;
flex-shrink: 0;
}
.header-left {
display: flex;
align-items: center;
gap: 6px;
min-width: 0;
}
.title {
font-size: 12px;
font-weight: 600;
color: #4fc3f7;
letter-spacing: 0.5px;
white-space: nowrap;
}
.status-dot {
width: 7px;
height: 7px;
border-radius: 50%;
background: #4caf50;
box-shadow: 0 0 6px #4caf50;
flex-shrink: 0;
}
.status-dot.disconnected {
background: #f44336;
box-shadow: 0 0 6px #f44336;
}
.heading-value {
font-size: 28px;
font-weight: 200;
color: #4fc3f7;
text-shadow: 0 0 15px rgba(79,195,247,0.5);
white-space: nowrap;
}
.target-indicator {
font-size: 16px;
font-weight: 400;
color: #ffc107;
margin-left: 8px;
text-shadow: 0 0 10px rgba(255,193,7,0.6);
animation: pulse 1s ease-in-out infinite;
}
@keyframes pulse {
0%, 100% { opacity: 0.8; }
50% { opacity: 1; }
}
.controls-compact {
display: flex;
gap: 4px;
flex-shrink: 0;
}
.btn-mini {
width: 30px;
height: 30px;
border: 2px solid rgba(79,195,247,0.3);
border-radius: 5px;
font-size: 17px;
cursor: pointer;
display: flex;
align-items: center;
justify-content: center;
color: rgba(255,255,255,0.7);
background: rgba(79,195,247,0.08);
transition: all 0.2s;
}
.btn-mini:hover {
border-color: rgba(79,195,247,0.6);
color: rgba(255,255,255,0.9);
background: rgba(79,195,247,0.15);
}
.btn-mini.stop-btn:hover {
border-color: #f44336;
color: #f44336;
background: rgba(244,67,54,0.15);
}
.map-container {
flex: 1;
display: flex;
align-items: center;
justify-content: center;
padding: 6px;
background: rgba(10,22,40,0.6);
min-height: 0;
}
.map-svg {
width: 100%;
height: 100%;
max-width: 360px;
max-height: 360px;
cursor: crosshair;
outline: none;
}
.map-svg:hover {
filter: brightness(1.1);
}
.cardinal {
fill: #4fc3f7;
font-size: 16px;
font-weight: 700;
}
.degree-label {
fill: rgba(79,195,247,0.7);
font-size: 12px;
font-weight: 600;
}
.dir-row {
display: grid;
grid-template-columns: repeat(3, 1fr);
gap: 6px;
padding: 8px;
border-top: 1px solid #2d3748;
flex-shrink: 0;
}
.dir-btn {
padding: 8px 0;
border: 2px solid rgba(79,195,247,0.3);
border-radius: 6px;
font-size: 12px;
font-weight: 600;
text-transform: uppercase;
cursor: pointer;
color: rgba(255,255,255,0.7);
background: rgba(79,195,247,0.08);
letter-spacing: 0.5px;
transition: all 0.2s;
}
.dir-btn:hover {
border-color: rgba(79,195,247,0.6);
color: rgba(255,255,255,0.9);
background: rgba(79,195,247,0.15);
}
.dir-btn.active {
border-color: #4fc3f7;
color: #4fc3f7;
background: rgba(79,195,247,0.2);
box-shadow: 0 0 15px rgba(79,195,247,0.3);
font-weight: 700;
}
</style>