popup
This commit is contained in:
1
.gitignore
vendored
1
.gitignore
vendored
@@ -38,6 +38,7 @@ web/dist/
|
|||||||
web/build/
|
web/build/
|
||||||
web/.svelte-kit/
|
web/.svelte-kit/
|
||||||
web/package-lock.json
|
web/package-lock.json
|
||||||
|
cmd/server/web
|
||||||
|
|
||||||
# Logs
|
# Logs
|
||||||
*.log
|
*.log
|
||||||
2
Makefile
2
Makefile
@@ -46,7 +46,7 @@ frontend:
|
|||||||
## backend: Build le backend Go
|
## backend: Build le backend Go
|
||||||
backend: frontend
|
backend: frontend
|
||||||
@echo "Building Go binary..."
|
@echo "Building Go binary..."
|
||||||
cd $(BACKEND_DIR) && go build -ldflags -H=windowsgui .
|
cd $(BACKEND_DIR) && go build -o ../../SMaster.exe -ldflags -H=windowsgui .
|
||||||
@echo "Backend built successfully"
|
@echo "Backend built successfully"
|
||||||
|
|
||||||
## build: Build complet (frontend + backend)
|
## build: Build complet (frontend + backend)
|
||||||
|
|||||||
BIN
cmd/server/icon.ico
Normal file
BIN
cmd/server/icon.ico
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 4.2 KiB |
@@ -44,8 +44,11 @@ func main() {
|
|||||||
log.Fatalf("Failed to start device manager: %v", err)
|
log.Fatalf("Failed to start device manager: %v", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Channel de shutdown partagé entre main et le handler API
|
||||||
|
shutdownChan := make(chan struct{})
|
||||||
|
|
||||||
// Create HTTP server with embedded files
|
// Create HTTP server with embedded files
|
||||||
server := api.NewServer(deviceManager, hub, cfg)
|
server := api.NewServer(deviceManager, hub, cfg, shutdownChan)
|
||||||
mux := server.SetupRoutes()
|
mux := server.SetupRoutes()
|
||||||
|
|
||||||
// Serve embedded static files
|
// Serve embedded static files
|
||||||
@@ -76,12 +79,17 @@ func main() {
|
|||||||
}
|
}
|
||||||
}()
|
}()
|
||||||
|
|
||||||
// Wait for interrupt signal
|
// Wait for interrupt signal or API shutdown request
|
||||||
quit := make(chan os.Signal, 1)
|
quit := make(chan os.Signal, 1)
|
||||||
signal.Notify(quit, syscall.SIGINT, syscall.SIGTERM)
|
signal.Notify(quit, syscall.SIGINT, syscall.SIGTERM)
|
||||||
<-quit
|
|
||||||
|
|
||||||
log.Println("Shutting down server...")
|
select {
|
||||||
|
case <-quit:
|
||||||
|
log.Println("Signal received, shutting down...")
|
||||||
|
case <-shutdownChan:
|
||||||
|
log.Println("API shutdown requested, shutting down...")
|
||||||
|
}
|
||||||
|
|
||||||
deviceManager.Stop()
|
deviceManager.Stop()
|
||||||
log.Println("Server stopped")
|
log.Println("Server stopped")
|
||||||
}
|
}
|
||||||
|
|||||||
BIN
cmd/server/resource.syso
Normal file
BIN
cmd/server/resource.syso
Normal file
Binary file not shown.
5
cmd/server/web/dist/index.html
vendored
5
cmd/server/web/dist/index.html
vendored
@@ -7,8 +7,9 @@
|
|||||||
<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-Ci4y1GIJ.js"></script>
|
<script type="module" crossorigin src="/assets/main-CEFSEmZ6.js"></script>
|
||||||
<link rel="stylesheet" crossorigin href="/assets/index-B1UmG2DI.css">
|
<link rel="modulepreload" crossorigin href="/assets/api-C_k14kaa.js">
|
||||||
|
<link rel="stylesheet" crossorigin href="/assets/main-CuAW62oI.css">
|
||||||
</head>
|
</head>
|
||||||
<body>
|
<body>
|
||||||
<div id="app"></div>
|
<div id="app"></div>
|
||||||
|
|||||||
@@ -5,6 +5,7 @@ import (
|
|||||||
"log"
|
"log"
|
||||||
"net/http"
|
"net/http"
|
||||||
"strconv"
|
"strconv"
|
||||||
|
"time"
|
||||||
|
|
||||||
"git.rouggy.com/rouggy/ShackMaster/internal/config"
|
"git.rouggy.com/rouggy/ShackMaster/internal/config"
|
||||||
"github.com/gorilla/websocket"
|
"github.com/gorilla/websocket"
|
||||||
@@ -15,13 +16,15 @@ type Server struct {
|
|||||||
hub *Hub
|
hub *Hub
|
||||||
config *config.Config
|
config *config.Config
|
||||||
upgrader websocket.Upgrader
|
upgrader websocket.Upgrader
|
||||||
|
shutdownChan chan struct{}
|
||||||
}
|
}
|
||||||
|
|
||||||
func NewServer(dm *DeviceManager, hub *Hub, cfg *config.Config) *Server {
|
func NewServer(dm *DeviceManager, hub *Hub, cfg *config.Config, shutdownChan chan struct{}) *Server {
|
||||||
return &Server{
|
return &Server{
|
||||||
deviceManager: dm,
|
deviceManager: dm,
|
||||||
hub: hub,
|
hub: hub,
|
||||||
config: cfg,
|
config: cfg,
|
||||||
|
shutdownChan: shutdownChan,
|
||||||
upgrader: websocket.Upgrader{
|
upgrader: websocket.Upgrader{
|
||||||
ReadBufferSize: 1024,
|
ReadBufferSize: 1024,
|
||||||
WriteBufferSize: 1024,
|
WriteBufferSize: 1024,
|
||||||
@@ -74,6 +77,9 @@ func (s *Server) SetupRoutes() *http.ServeMux {
|
|||||||
mux.HandleFunc("/api/power/fanmode", s.handlePowerFanMode)
|
mux.HandleFunc("/api/power/fanmode", s.handlePowerFanMode)
|
||||||
mux.HandleFunc("/api/power/operate", s.handlePowerOperate)
|
mux.HandleFunc("/api/power/operate", s.handlePowerOperate)
|
||||||
|
|
||||||
|
// Shutdown endpoint
|
||||||
|
mux.HandleFunc("/api/shutdown", s.handleShutdown)
|
||||||
|
|
||||||
// Note: Static files are now served from embedded FS in main.go
|
// Note: Static files are now served from embedded FS in main.go
|
||||||
|
|
||||||
return mux
|
return mux
|
||||||
@@ -510,6 +516,21 @@ func (s *Server) handleUltrabeamDirection(w http.ResponseWriter, r *http.Request
|
|||||||
s.sendJSON(w, map[string]string{"status": "ok"})
|
s.sendJSON(w, map[string]string{"status": "ok"})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func (s *Server) handleShutdown(w http.ResponseWriter, r *http.Request) {
|
||||||
|
if r.Method != http.MethodPost {
|
||||||
|
http.Error(w, "Method not allowed", http.StatusMethodNotAllowed)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
s.sendJSON(w, map[string]string{"status": "shutting down"})
|
||||||
|
|
||||||
|
go func() {
|
||||||
|
time.Sleep(200 * time.Millisecond)
|
||||||
|
log.Println("Shutdown requested via API")
|
||||||
|
close(s.shutdownChan)
|
||||||
|
}()
|
||||||
|
}
|
||||||
|
|
||||||
func (s *Server) sendJSON(w http.ResponseWriter, data interface{}) {
|
func (s *Server) sendJSON(w http.ResponseWriter, data interface{}) {
|
||||||
w.Header().Set("Content-Type", "application/json")
|
w.Header().Set("Content-Type", "application/json")
|
||||||
json.NewEncoder(w).Encode(data)
|
json.NewEncoder(w).Encode(data)
|
||||||
|
|||||||
@@ -101,13 +101,14 @@ func (c *Client) GetWeatherData() (*WeatherData, error) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Convert to our structure
|
// Convert to our structure
|
||||||
|
// OWM retourne wind_speed et gust en m/s — conversion en km/h
|
||||||
weatherData := &WeatherData{
|
weatherData := &WeatherData{
|
||||||
Temperature: owmData.Main.Temp,
|
Temperature: owmData.Main.Temp,
|
||||||
FeelsLike: owmData.Main.FeelsLike,
|
FeelsLike: owmData.Main.FeelsLike,
|
||||||
Humidity: owmData.Main.Humidity,
|
Humidity: owmData.Main.Humidity,
|
||||||
Pressure: owmData.Main.Pressure,
|
Pressure: owmData.Main.Pressure,
|
||||||
WindSpeed: owmData.Wind.Speed,
|
WindSpeed: owmData.Wind.Speed * 3.6,
|
||||||
WindGust: owmData.Wind.Gust,
|
WindGust: owmData.Wind.Gust * 3.6,
|
||||||
WindDeg: owmData.Wind.Deg,
|
WindDeg: owmData.Wind.Deg,
|
||||||
Clouds: owmData.Clouds.All,
|
Clouds: owmData.Clouds.All,
|
||||||
UpdatedAt: time.Unix(owmData.Dt, 0).Format(time.RFC3339),
|
UpdatedAt: time.Unix(owmData.Dt, 0).Format(time.RFC3339),
|
||||||
|
|||||||
16
web/popup.html
Normal file
16
web/popup.html
Normal file
@@ -0,0 +1,16 @@
|
|||||||
|
<!DOCTYPE html>
|
||||||
|
<html lang="en">
|
||||||
|
<head>
|
||||||
|
<meta charset="UTF-8">
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||||
|
<title>Rotator Control</title>
|
||||||
|
<style>
|
||||||
|
* { margin: 0; padding: 0; box-sizing: border-box; }
|
||||||
|
body { background: #0f1923; overflow: hidden; }
|
||||||
|
</style>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<div id="popup-app"></div>
|
||||||
|
<script type="module" src="/src/popup.js"></script>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
@@ -71,6 +71,14 @@
|
|||||||
return date.toTimeString().slice(0, 8);
|
return date.toTimeString().slice(0, 8);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async function shutdown() {
|
||||||
|
try {
|
||||||
|
await api.shutdown();
|
||||||
|
} catch (e) {
|
||||||
|
// Connexion coupee apres shutdown, c'est normal
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// Weather data from status
|
// Weather data from status
|
||||||
$: weatherData = status?.weather || {
|
$: weatherData = status?.weather || {
|
||||||
wind_speed: 0,
|
wind_speed: 0,
|
||||||
@@ -83,11 +91,14 @@
|
|||||||
<div class="app">
|
<div class="app">
|
||||||
<header>
|
<header>
|
||||||
<div class="header-left">
|
<div class="header-left">
|
||||||
<h1>{callsign} Shack</h1>
|
<h1>{callsign}'s Shack</h1>
|
||||||
<div class="connection-status">
|
<div class="connection-status">
|
||||||
<span class="status-indicator" class:status-online={isConnected} class:status-offline={!isConnected}></span>
|
<span class="status-indicator" class:status-online={isConnected} class:status-offline={!isConnected}></span>
|
||||||
{isConnected ? 'Connected' : 'Disconnected'}
|
{isConnected ? 'Connected' : 'Disconnected'}
|
||||||
</div>
|
</div>
|
||||||
|
<button class="shutdown-btn" on:click={shutdown} title="Fermer ShackMaster">
|
||||||
|
⏻ Shutdown
|
||||||
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="header-center">
|
<div class="header-center">
|
||||||
@@ -183,6 +194,25 @@
|
|||||||
border-radius: 16px;
|
border-radius: 16px;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.shutdown-btn {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 6px;
|
||||||
|
font-size: 13px;
|
||||||
|
padding: 6px 12px;
|
||||||
|
background: rgba(239, 68, 68, 0.15);
|
||||||
|
color: #f87171;
|
||||||
|
border: 1px solid rgba(239, 68, 68, 0.3);
|
||||||
|
border-radius: 16px;
|
||||||
|
cursor: pointer;
|
||||||
|
transition: background 0.2s;
|
||||||
|
}
|
||||||
|
|
||||||
|
.shutdown-btn:hover {
|
||||||
|
background: rgba(239, 68, 68, 0.35);
|
||||||
|
color: #fca5a5;
|
||||||
|
}
|
||||||
|
|
||||||
.header-center {
|
.header-center {
|
||||||
flex: 1;
|
flex: 1;
|
||||||
display: flex;
|
display: flex;
|
||||||
|
|||||||
448
web/src/PopupApp.svelte
Normal file
448
web/src/PopupApp.svelte
Normal file
@@ -0,0 +1,448 @@
|
|||||||
|
<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>
|
||||||
@@ -165,6 +165,7 @@
|
|||||||
h2 {
|
h2 {
|
||||||
font-size: 14px;
|
font-size: 14px;
|
||||||
font-weight: 600;
|
font-weight: 600;
|
||||||
|
line-height: 1;
|
||||||
color: var(--accent-cyan);
|
color: var(--accent-cyan);
|
||||||
margin: 0;
|
margin: 0;
|
||||||
letter-spacing: 0.5px;
|
letter-spacing: 0.5px;
|
||||||
|
|||||||
@@ -93,6 +93,27 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Handle click on compass to set heading
|
// Handle click on compass to set heading
|
||||||
|
let popupWindow = null;
|
||||||
|
|
||||||
|
function openPopup() {
|
||||||
|
const features = [
|
||||||
|
'width=380',
|
||||||
|
'height=460',
|
||||||
|
'toolbar=no',
|
||||||
|
'menubar=no',
|
||||||
|
'scrollbars=no',
|
||||||
|
'resizable=yes',
|
||||||
|
'status=no',
|
||||||
|
'location=no',
|
||||||
|
'popup=yes',
|
||||||
|
].join(',');
|
||||||
|
if (popupWindow && !popupWindow.closed) {
|
||||||
|
popupWindow.focus();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
popupWindow = window.open('/popup.html', 'rotator-popup', features);
|
||||||
|
}
|
||||||
|
|
||||||
async function handleCompassClick(event) {
|
async function handleCompassClick(event) {
|
||||||
const svg = event.currentTarget;
|
const svg = event.currentTarget;
|
||||||
const rect = svg.getBoundingClientRect();
|
const rect = svg.getBoundingClientRect();
|
||||||
@@ -125,8 +146,11 @@
|
|||||||
<div class="card">
|
<div class="card">
|
||||||
<div class="card-header">
|
<div class="card-header">
|
||||||
<h2>Rotator Genius</h2>
|
<h2>Rotator Genius</h2>
|
||||||
|
<div class="header-right">
|
||||||
|
<button class="btn-popup" on:click={openPopup} title="Open popup window">⊞</button>
|
||||||
<span class="status-dot" class:disconnected={!connected}></span>
|
<span class="status-dot" class:disconnected={!connected}></span>
|
||||||
</div>
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
<div class="metrics">
|
<div class="metrics">
|
||||||
<!-- Current Heading Display with Compact Controls -->
|
<!-- Current Heading Display with Compact Controls -->
|
||||||
@@ -385,11 +409,41 @@
|
|||||||
h2 {
|
h2 {
|
||||||
font-size: 14px;
|
font-size: 14px;
|
||||||
font-weight: 600;
|
font-weight: 600;
|
||||||
|
line-height: 1;
|
||||||
color: var(--accent-cyan);
|
color: var(--accent-cyan);
|
||||||
margin: 0;
|
margin: 0;
|
||||||
letter-spacing: 0.5px;
|
letter-spacing: 0.5px;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.header-right {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-popup {
|
||||||
|
width: 24px;
|
||||||
|
height: 24px;
|
||||||
|
border: 1px solid rgba(79,195,247,0.3);
|
||||||
|
border-radius: 4px;
|
||||||
|
font-size: 14px;
|
||||||
|
cursor: pointer;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
color: rgba(79,195,247,0.6);
|
||||||
|
background: transparent;
|
||||||
|
padding: 0;
|
||||||
|
line-height: 1;
|
||||||
|
transition: all 0.2s;
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-popup:hover {
|
||||||
|
border-color: rgba(79,195,247,0.7);
|
||||||
|
color: #4fc3f7;
|
||||||
|
background: rgba(79,195,247,0.1);
|
||||||
|
}
|
||||||
|
|
||||||
.status-dot {
|
.status-dot {
|
||||||
width: 8px;
|
width: 8px;
|
||||||
height: 8px;
|
height: 8px;
|
||||||
|
|||||||
@@ -162,7 +162,7 @@
|
|||||||
|
|
||||||
<div class="card">
|
<div class="card">
|
||||||
<div class="card-header">
|
<div class="card-header">
|
||||||
<h2>Ultrabeam VL2.3</h2>
|
<div class="card-title">Ultrabeam VL2.3</div>
|
||||||
<span class="status-dot" class:disconnected={!connected}></span>
|
<span class="status-dot" class:disconnected={!connected}></span>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -347,10 +347,11 @@
|
|||||||
gap: 12px;
|
gap: 12px;
|
||||||
}
|
}
|
||||||
|
|
||||||
h2 {
|
.card-title {
|
||||||
margin: 0;
|
margin: 0;
|
||||||
font-size: 14px;
|
font-size: 14px;
|
||||||
font-weight: 600;
|
font-weight: 600;
|
||||||
|
line-height: 1;
|
||||||
color: #4fc3f7;
|
color: #4fc3f7;
|
||||||
letter-spacing: 0.5px;
|
letter-spacing: 0.5px;
|
||||||
}
|
}
|
||||||
@@ -363,11 +364,11 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
.status-dot {
|
.status-dot {
|
||||||
width: 12px;
|
width: 8px;
|
||||||
height: 12px;
|
height: 8px;
|
||||||
border-radius: 50%;
|
border-radius: 50%;
|
||||||
background: #4caf50;
|
background: #4caf50;
|
||||||
box-shadow: 0 0 12px rgba(76, 175, 80, 0.8);
|
box-shadow: 0 0 8px #4caf50;
|
||||||
animation: pulse 2s ease-in-out infinite;
|
animation: pulse 2s ease-in-out infinite;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -110,4 +110,6 @@ export const api = {
|
|||||||
body: JSON.stringify({ direction }),
|
body: JSON.stringify({ direction }),
|
||||||
}),
|
}),
|
||||||
},
|
},
|
||||||
|
// Shutdown
|
||||||
|
shutdown: () => request('/shutdown', { method: 'POST' }),
|
||||||
};
|
};
|
||||||
7
web/src/popup.js
Normal file
7
web/src/popup.js
Normal file
@@ -0,0 +1,7 @@
|
|||||||
|
import PopupApp from './PopupApp.svelte';
|
||||||
|
|
||||||
|
const app = new PopupApp({
|
||||||
|
target: document.getElementById('popup-app'),
|
||||||
|
});
|
||||||
|
|
||||||
|
export default app;
|
||||||
@@ -4,7 +4,13 @@ import { svelte } from '@sveltejs/vite-plugin-svelte'
|
|||||||
export default defineConfig({
|
export default defineConfig({
|
||||||
plugins: [svelte()],
|
plugins: [svelte()],
|
||||||
build: {
|
build: {
|
||||||
outDir: 'dist'
|
outDir: 'dist',
|
||||||
|
rollupOptions: {
|
||||||
|
input: {
|
||||||
|
main: 'index.html',
|
||||||
|
popup: 'popup.html',
|
||||||
|
}
|
||||||
|
}
|
||||||
},
|
},
|
||||||
server: {
|
server: {
|
||||||
port: 5173,
|
port: 5173,
|
||||||
|
|||||||
Reference in New Issue
Block a user