first commit
This commit is contained in:
267
frontend/src/App.svelte
Normal file
267
frontend/src/App.svelte
Normal file
@@ -0,0 +1,267 @@
|
||||
<script>
|
||||
import { onMount, onDestroy } from 'svelte'
|
||||
import StatusBar from './components/StatusBar.svelte'
|
||||
import WorldMap from './components/WorldMap.svelte'
|
||||
import SatSelector from './components/SatSelector.svelte'
|
||||
import PassesPanel from './components/PassesPanel.svelte'
|
||||
import SettingsPanel from './components/SettingsPanel.svelte'
|
||||
import PolarPlot from './components/PolarPlot.svelte'
|
||||
import { Go, onWailsEvent } from './lib/wails.js'
|
||||
import { get } from 'svelte/store'
|
||||
import { satPositions, satList, trackedSat, trackedPosition, passes, tleAge, tleLoaded, flexConnected, rotorConnected, settings, watchlist, soundEnabled, passesAllCache } from './stores/satstore.js'
|
||||
|
||||
let activeTab = 'map'
|
||||
let cleanups = []
|
||||
let mapComponent
|
||||
|
||||
import { dopplerEnabled, rotorEnabled } from './stores/satstore.js'
|
||||
import { getDisplayName } from './lib/satdb.js'
|
||||
|
||||
// Sync toggles → Go backend
|
||||
$: Go.SetDopplerEnabled($dopplerEnabled)
|
||||
$: Go.SetRotorEnabled($rotorEnabled)
|
||||
|
||||
onMount(async () => {
|
||||
const s = $settings
|
||||
await Go.SetObserverLocation(s.qthLat, s.qthLon, s.qthAlt)
|
||||
await Go.SetSatelliteFrequencies(s.downlinkHz, s.uplinkHz)
|
||||
|
||||
if (s.autoConnectFlex) {
|
||||
const res = await Go.ConnectFlexRadio(s.flexHost, s.flexPort)
|
||||
if (res === 'OK') {
|
||||
flexConnected.set(true)
|
||||
// Apply slice config from settings immediately after connect
|
||||
const rx = s.rxSlice ?? 0
|
||||
const tx = s.txSlice ?? 1
|
||||
await Go.SetSliceConfig(rx, tx)
|
||||
}
|
||||
}
|
||||
if (s.autoConnectRotor) {
|
||||
const res = await Go.ConnectRotor(s.rotorHost, s.rotorPort)
|
||||
if (res === 'OK') {
|
||||
rotorConnected.set(true)
|
||||
await Go.SetRotorAzOnly(s.rotorAzOnly ?? true)
|
||||
}
|
||||
}
|
||||
|
||||
const names = await Go.GetSatelliteList()
|
||||
if (names) satList.set(names)
|
||||
const age = await Go.GetTLEAge()
|
||||
if (age !== null) tleAge.set(age)
|
||||
await Go.SetWatchlist($watchlist)
|
||||
|
||||
cleanups.push(onWailsEvent('sat:positions', p => satPositions.set(p)))
|
||||
cleanups.push(onWailsEvent('tle:loaded', names => {
|
||||
satList.set(names); tleLoaded.set(true)
|
||||
Go.GetTLEAge().then(a => tleAge.set(a))
|
||||
}))
|
||||
})
|
||||
|
||||
onDestroy(() => cleanups.forEach(fn => fn?.()))
|
||||
|
||||
function handleKey(e) {
|
||||
if (e.target.tagName === 'INPUT' || e.target.tagName === 'SELECT') return
|
||||
if (e.key === 'm' || e.key === 'M') activeTab = 'map'
|
||||
if (e.key === 'p' || e.key === 'P') activeTab = 'passes'
|
||||
if (e.key === 's' || e.key === 'S') activeTab = 'settings'
|
||||
}
|
||||
|
||||
$: currentAz = $trackedPosition?.az ?? null
|
||||
$: currentEl = $trackedPosition?.el ?? null
|
||||
|
||||
// ── AOS Voice Alert Engine ──────────────────────────────────────────────
|
||||
// Track previous elevation for each sat to detect the neg→pos crossing
|
||||
let _prevElevations = {}
|
||||
|
||||
// Pick voice: use persisted setting, fallback to best English voice
|
||||
function getVoice() {
|
||||
const voices = window.speechSynthesis.getVoices()
|
||||
const saved = $settings.ttsVoiceName
|
||||
if (saved) {
|
||||
const v = voices.find(v => v.name === saved)
|
||||
if (v) return v
|
||||
}
|
||||
// Fallback: preferred Windows English voices
|
||||
const preferred = ['Microsoft David', 'Microsoft Zira', 'Microsoft Mark', 'Microsoft James', 'Microsoft Linda', 'Microsoft George', 'Microsoft Hazel']
|
||||
for (const name of preferred) {
|
||||
const v = voices.find(v => v.name.startsWith(name))
|
||||
if (v) return v
|
||||
}
|
||||
return voices.find(v => v.lang === 'en-US' || v.lang === 'en-GB') || null
|
||||
}
|
||||
|
||||
function announceAOS(satName, durationSecs) {
|
||||
if (!window.speechSynthesis) return
|
||||
const display = getDisplayName(satName)
|
||||
const mins = Math.round(durationSecs / 60)
|
||||
const text = `AOS ${display} is rising. Pass will last for ${mins} minute${mins !== 1 ? 's' : ''}.`
|
||||
const utt = new SpeechSynthesisUtterance(text)
|
||||
utt.lang = 'en-US'
|
||||
utt.rate = 0.95
|
||||
utt.pitch = 1.0
|
||||
utt.volume = 1.0
|
||||
const voice = getVoice()
|
||||
if (voice) utt.voice = voice
|
||||
window.speechSynthesis.cancel()
|
||||
window.speechSynthesis.speak(utt)
|
||||
}
|
||||
|
||||
// Watch satPositions for AOS crossings on all watchlist sats
|
||||
satPositions.subscribe(positions => {
|
||||
if (!positions || !$soundEnabled) return
|
||||
const wl = new Set($watchlist)
|
||||
for (const pos of positions) {
|
||||
if (!wl.has(pos.name)) continue
|
||||
const prev = _prevElevations[pos.name]
|
||||
const curr = pos.el
|
||||
// Detect crossing from below horizon to above (neg → pos)
|
||||
if (prev !== undefined && prev < 0 && curr >= 0) {
|
||||
// Find pass duration from passesAllCache or tracked sat passes
|
||||
let durationSecs = 600 // fallback 10 min
|
||||
const now = Date.now()
|
||||
const allCache = get(passesAllCache)
|
||||
const trackedPasses = get(passes)
|
||||
const allAvail = [...(allCache || []), ...(trackedPasses || []).map(p => ({...p, satName: get(trackedSat)}))]
|
||||
const match = allAvail.find(p =>
|
||||
p.satName === pos.name &&
|
||||
new Date(p.aos).getTime() <= now + 30000 &&
|
||||
new Date(p.los).getTime() >= now
|
||||
)
|
||||
if (match?.duration) durationSecs = match.duration
|
||||
announceAOS(pos.name, durationSecs)
|
||||
}
|
||||
_prevElevations[pos.name] = curr
|
||||
}
|
||||
})
|
||||
</script>
|
||||
|
||||
<svelte:window on:keydown={handleKey} />
|
||||
<svelte:head>
|
||||
<link href="https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600;700;800&display=swap" rel="stylesheet"/>
|
||||
</svelte:head>
|
||||
|
||||
<div class="app">
|
||||
<StatusBar />
|
||||
|
||||
<div class="main">
|
||||
<!-- Left sidebar -->
|
||||
<aside class="sidebar">
|
||||
<div class="sidebar-hdr">
|
||||
<div class="logo-wrap">
|
||||
<div class="logo-icon">🛰</div>
|
||||
<div>
|
||||
<div class="logo-text">SatMaster</div>
|
||||
<div class="logo-sub">Satellite Tracker</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<SatSelector />
|
||||
</aside>
|
||||
|
||||
<!-- Main content -->
|
||||
<main class="center">
|
||||
<nav class="tabs">
|
||||
<button class="tab {activeTab==='map'?'on':''}" on:click={() => activeTab='map'}>
|
||||
<span class="tab-icon">🗺</span> World Map
|
||||
</button>
|
||||
<button class="tab {activeTab==='passes'?'on':''}" on:click={() => activeTab='passes'}>
|
||||
<span class="tab-icon">📡</span> Passes
|
||||
</button>
|
||||
<button class="tab {activeTab==='settings'?'on':''}" on:click={() => activeTab='settings'}>
|
||||
<span class="tab-icon">⚙️</span> Settings
|
||||
</button>
|
||||
</nav>
|
||||
|
||||
<div class="panel-stack">
|
||||
<!-- Map: always in DOM, hidden via visibility when not active so Leaflet keeps its size -->
|
||||
<div class="panel" class:panel-hidden={activeTab !== 'map'}>
|
||||
<WorldMap bind:this={mapComponent}/>
|
||||
{#if $trackedSat && activeTab === 'map'}
|
||||
<div class="polar-ov">
|
||||
<PolarPlot points={$passes[0]?.points||[]} currentAz={currentAz} currentEl={currentEl} satName={$trackedSat} size={200}/>
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
{#if activeTab === 'passes'}
|
||||
<div class="panel">
|
||||
<PassesPanel currentAz={currentAz} currentEl={currentEl}/>
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
{#if activeTab === 'settings'}
|
||||
<div class="panel">
|
||||
<SettingsPanel/>
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
</main>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<style>
|
||||
:global(*,*::before,*::after){box-sizing:border-box;margin:0;padding:0}
|
||||
:global(body){background:#f0f2f5;overflow:hidden;user-select:none;-webkit-user-select:none;font-family:'Inter',system-ui,sans-serif}
|
||||
:global(::-webkit-scrollbar){width:5px}
|
||||
:global(::-webkit-scrollbar-track){background:#f0f2f5}
|
||||
:global(::-webkit-scrollbar-thumb){background:#c0ccd8;border-radius:3px}
|
||||
|
||||
.app { display:flex; flex-direction:column; height:100vh; width:100vw; background:#f0f2f5; }
|
||||
.main { display:flex; flex:1; overflow:hidden; min-height:0; gap:12px; padding:0 12px 12px; }
|
||||
|
||||
/* Sidebar */
|
||||
.sidebar {
|
||||
width:280px; flex-shrink:0;
|
||||
display:flex; flex-direction:column;
|
||||
background:white; border-radius:12px;
|
||||
box-shadow:0 1px 4px rgba(0,0,0,0.08);
|
||||
overflow:hidden; margin-top:8px;
|
||||
}
|
||||
.sidebar-hdr {
|
||||
padding:14px 16px; border-bottom:1px solid #e8edf2;
|
||||
background:white; flex-shrink:0;
|
||||
}
|
||||
.logo-wrap { display:flex; align-items:center; gap:10px; }
|
||||
.logo-icon { font-size:24px; }
|
||||
.logo-text { font-size:15px; font-weight:700; color:#1a2a3a; letter-spacing:-.01em; }
|
||||
.logo-sub { font-size:10px; color:#7a9ab8; margin-top:1px; }
|
||||
|
||||
/* Center */
|
||||
.center {
|
||||
flex:1; display:flex; flex-direction:column;
|
||||
overflow:hidden; min-width:0; min-height:0;
|
||||
margin-top:8px;
|
||||
}
|
||||
|
||||
/* Tabs */
|
||||
.tabs {
|
||||
display:flex; gap:4px;
|
||||
background:white; border-radius:10px;
|
||||
padding:6px; margin-bottom:8px;
|
||||
box-shadow:0 1px 4px rgba(0,0,0,0.06);
|
||||
flex-shrink:0;
|
||||
}
|
||||
.tab {
|
||||
display:flex; align-items:center; gap:6px;
|
||||
background:none; border:none;
|
||||
color:#6a8aa8; font-family:'Inter',sans-serif;
|
||||
font-size:13px; font-weight:500;
|
||||
padding:8px 16px; border-radius:7px;
|
||||
cursor:pointer; transition:all .15s;
|
||||
}
|
||||
.tab:hover { background:#f0f4f8; color:#1a2a3a; }
|
||||
.tab.on { background:#1a5a9a; color:white; font-weight:600; }
|
||||
.tab-icon { font-size:14px; }
|
||||
|
||||
/* Panel stack */
|
||||
.panel-stack { position:relative; flex:1; overflow:hidden; min-height:0; border-radius:12px; box-shadow:0 1px 4px rgba(0,0,0,0.08); }
|
||||
.panel { position:absolute; top:0; left:0; right:0; bottom:0; width:100%; height:100%; border-radius:12px; overflow:hidden; background:white; }
|
||||
.panel-hidden { visibility:hidden; pointer-events:none; }
|
||||
|
||||
.polar-ov {
|
||||
position:absolute; top:14px; right:14px; z-index:500;
|
||||
background:rgba(255,255,255,0.92); border:1px solid #d0dce8;
|
||||
border-radius:50%; box-shadow:0 4px 16px rgba(0,0,0,0.12);
|
||||
padding:3px; backdrop-filter:blur(4px);
|
||||
}
|
||||
</style>
|
||||
356
frontend/src/components/PassesPanel.svelte
Normal file
356
frontend/src/components/PassesPanel.svelte
Normal file
@@ -0,0 +1,356 @@
|
||||
<script>
|
||||
import { passes, trackedSat, watchlist, satList, passesViewMode, passesMinEl, passesAllCache } from '../stores/satstore.js'
|
||||
import { Go } from '../lib/wails.js'
|
||||
import { formatDuration, formatEl, formatAz, formatUTC, passQuality } from '../lib/utils.js'
|
||||
import PolarPlot from './PolarPlot.svelte'
|
||||
import { onMount, onDestroy } from 'svelte'
|
||||
|
||||
export let currentAz = null
|
||||
export let currentEl = null
|
||||
|
||||
let now = Date.now()
|
||||
let ticker
|
||||
let selectedPass = null
|
||||
let selectedPassSat = null
|
||||
|
||||
// View mode and filter — persisted in stores so they survive tab switches
|
||||
$: viewMode = $passesViewMode
|
||||
$: minEl = $passesMinEl
|
||||
|
||||
// All passes cache — persisted in store (in-memory, not localStorage)
|
||||
$: allPasses = $passesAllCache
|
||||
let loadingAll = false
|
||||
|
||||
onMount(() => {
|
||||
ticker = setInterval(() => { now = Date.now() }, 1000)
|
||||
})
|
||||
onDestroy(() => clearInterval(ticker))
|
||||
|
||||
async function loadAllPasses() {
|
||||
if (loadingAll) return
|
||||
loadingAll = true
|
||||
passesAllCache.set([])
|
||||
const wl = $watchlist
|
||||
const results = await Promise.all(
|
||||
wl.map(async name => {
|
||||
try {
|
||||
const ps = await Go.GetPasses(name, 24)
|
||||
return (ps || []).map(p => ({ ...p, satName: name }))
|
||||
} catch { return [] }
|
||||
})
|
||||
)
|
||||
passesAllCache.set(results.flat())
|
||||
loadingAll = false
|
||||
}
|
||||
|
||||
$: if ($passesViewMode === 'all' && $passesAllCache.length === 0 && !loadingAll) {
|
||||
loadAllPasses()
|
||||
}
|
||||
|
||||
// Source passes based on mode
|
||||
$: sourcePasses = $passesViewMode === 'single'
|
||||
? ($passes || []).map(p => ({ ...p, satName: $trackedSat }))
|
||||
: $passesAllCache
|
||||
|
||||
// Filter and sort
|
||||
$: filteredPasses = sourcePasses
|
||||
.filter(p => {
|
||||
if (new Date(p.los).getTime() <= now) return false
|
||||
if (p.maxEl < $passesMinEl) return false
|
||||
return true
|
||||
})
|
||||
.sort((a, b) => new Date(a.aos) - new Date(b.aos))
|
||||
|
||||
$: if (filteredPasses.length > 0 && !selectedPass) {
|
||||
selectedPass = filteredPasses[0]
|
||||
selectedPassSat = filteredPasses[0].satName
|
||||
}
|
||||
|
||||
function selectPass(p) {
|
||||
selectedPass = p
|
||||
selectedPassSat = p.satName
|
||||
}
|
||||
|
||||
function isActive(p) {
|
||||
const aos = new Date(p.aos).getTime()
|
||||
const los = new Date(p.los).getTime()
|
||||
return now >= aos && now <= los
|
||||
}
|
||||
|
||||
function countdown(p) {
|
||||
const aos = new Date(p.aos).getTime()
|
||||
const los = new Date(p.los).getTime()
|
||||
if (now >= aos && now <= los) {
|
||||
return { label: 'LOS in', secs: Math.floor((los - now) / 1000), live: true }
|
||||
}
|
||||
return { label: 'AOS in', secs: Math.floor((aos - now) / 1000), live: false }
|
||||
}
|
||||
|
||||
function fmtCountdown(secs) {
|
||||
if (secs <= 0) return '00:00'
|
||||
const h = Math.floor(secs / 3600)
|
||||
const m = Math.floor((secs % 3600) / 60)
|
||||
const s = secs % 60
|
||||
if (h > 0) return `${h}h ${String(m).padStart(2,'0')}m`
|
||||
return `${String(m).padStart(2,'0')}:${String(s).padStart(2,'0')}`
|
||||
}
|
||||
|
||||
function passProgress(p) {
|
||||
const aos = new Date(p.aos).getTime()
|
||||
const los = new Date(p.los).getTime()
|
||||
if (now < aos || now > los) return 0
|
||||
return Math.round((now - aos) / (los - aos) * 100)
|
||||
}
|
||||
|
||||
async function trackSatFromPass(satName) {
|
||||
if (!satName) return
|
||||
trackedSat.set(satName)
|
||||
const ps = await Go.GetPasses(satName, 24)
|
||||
passes.set(ps || [])
|
||||
await Go.StartTracking(satName)
|
||||
}
|
||||
|
||||
const EL_FILTERS = [
|
||||
{ label: 'All', value: 0 },
|
||||
{ label: '≥ 10°', value: 10 },
|
||||
{ label: '≥ 30°', value: 30 },
|
||||
{ label: '≥ 60° Excellent', value: 60 },
|
||||
]
|
||||
</script>
|
||||
|
||||
<div class="panel">
|
||||
<!-- Pass list -->
|
||||
<div class="pass-list">
|
||||
|
||||
<!-- Toolbar -->
|
||||
<div class="toolbar">
|
||||
<div class="view-toggle">
|
||||
<button class="vbtn {$passesViewMode==='single'?'on':''}" on:click={() => { passesViewMode.set('single'); selectedPass=null }}>
|
||||
This satellite
|
||||
</button>
|
||||
<button class="vbtn {$passesViewMode==='all'?'on':''}" on:click={() => { passesViewMode.set('all'); selectedPass=null; loadAllPasses() }}>
|
||||
All watchlist
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div class="el-filters">
|
||||
{#each EL_FILTERS as f}
|
||||
<button class="elfbtn {$passesMinEl===f.value?'on':''}" on:click={() => { passesMinEl.set(f.value); selectedPass=null }}>
|
||||
{f.label}
|
||||
</button>
|
||||
{/each}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Header -->
|
||||
<div class="list-hdr">
|
||||
<span class="hdr-title">
|
||||
{$passesViewMode === 'single' ? `PASSES — ${$trackedSat || '—'}` : 'ALL PASSES — WATCHLIST'}
|
||||
</span>
|
||||
<span class="hdr-count">
|
||||
{#if loadingAll}
|
||||
<span class="loading">Loading…</span>
|
||||
{:else}
|
||||
{filteredPasses.length} passes / 24h
|
||||
{/if}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
{#if $passesViewMode === 'single' && !$trackedSat}
|
||||
<div class="empty">Select a satellite to view its passes</div>
|
||||
{:else if filteredPasses.length === 0 && !loadingAll}
|
||||
<div class="empty">No pass{$passesMinEl > 0 ? ` with elevation ≥ ${$passesMinEl}°` : ''} in the next 24 hours</div>
|
||||
{:else}
|
||||
<div class="pass-rows">
|
||||
{#each filteredPasses as pass (pass.aos + pass.satName)}
|
||||
{@const q = passQuality(pass.maxEl)}
|
||||
{@const active = isActive(pass)}
|
||||
{@const cd = countdown(pass)}
|
||||
{@const progress = passProgress(pass)}
|
||||
{@const sel = selectedPass?.aos === pass.aos && selectedPassSat === pass.satName}
|
||||
|
||||
<div class="pass-row {sel?'selected':''} {active?'active':''}"
|
||||
on:click={() => selectPass(pass)}
|
||||
on:dblclick={() => trackSatFromPass(pass.satName)}>
|
||||
|
||||
<!-- Sat name (only in multi mode) -->
|
||||
{#if $passesViewMode === 'all'}
|
||||
<div class="pass-sat">{pass.satName} <span class="dbl-hint">double-click to track</span></div>
|
||||
{/if}
|
||||
|
||||
<div class="pass-body">
|
||||
<!-- Left -->
|
||||
<div class="pass-left">
|
||||
<span class="pass-time">{formatUTC(pass.aos)} UTC</span>
|
||||
<div class="countdown {cd.live?'live':cd.secs<300?'soon':''}">
|
||||
{#if cd.live}<span class="live-dot"></span>{/if}
|
||||
<span>{cd.label}</span>
|
||||
<span class="cd-val">{fmtCountdown(cd.secs)}</span>
|
||||
</div>
|
||||
{#if active && progress > 0}
|
||||
<div class="progress-bar">
|
||||
<div class="progress-fill" style="width:{progress}%"></div>
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
<!-- Center -->
|
||||
<div class="pass-center">
|
||||
<span class="max-el {q.cls}">▲ {formatEl(pass.maxEl)}</span>
|
||||
<span class="duration">⏱ {formatDuration(pass.duration)}</span>
|
||||
<span class="quality-badge {q.cls}">{q.label}</span>
|
||||
</div>
|
||||
|
||||
<!-- Right -->
|
||||
<div class="pass-right">
|
||||
<div class="az-row"><span class="az-lbl">AOS</span><span class="az-val">{formatAz(pass.aosAz)}</span></div>
|
||||
<div class="az-row"><span class="az-lbl">MAX</span><span class="az-val">{formatAz(pass.maxElAz)}</span></div>
|
||||
<div class="az-row"><span class="az-lbl">LOS</span><span class="az-val">{formatAz(pass.losAz)}</span></div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{/each}
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
<!-- Detail panel -->
|
||||
<div class="pass-detail">
|
||||
<div class="polar-wrap">
|
||||
<PolarPlot
|
||||
points={selectedPass?.points || []}
|
||||
currentAz={currentAz}
|
||||
currentEl={currentEl}
|
||||
satName={selectedPassSat || $trackedSat}
|
||||
size={220}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{#if selectedPass}
|
||||
{@const q = passQuality(selectedPass.maxEl)}
|
||||
{@const cd = countdown(selectedPass)}
|
||||
<div class="detail-box">
|
||||
{#if $passesViewMode === 'all'}
|
||||
<div class="detail-satname">{selectedPassSat}</div>
|
||||
{/if}
|
||||
<div class="detail-title">{formatUTC(selectedPass.aos)} UTC</div>
|
||||
|
||||
<div class="detail-countdown {cd.live?'live':cd.secs<300?'soon':''}">
|
||||
{#if cd.live}<span class="live-dot"></span>{/if}
|
||||
<span class="cd-label">{cd.label}</span>
|
||||
<span class="cd-big">{fmtCountdown(cd.secs)}</span>
|
||||
</div>
|
||||
|
||||
<div class="detail-grid">
|
||||
<span class="dk">AOS</span> <span class="dv">{formatUTC(selectedPass.aos)} UTC</span>
|
||||
<span class="dk">LOS</span> <span class="dv">{formatUTC(selectedPass.los)} UTC</span>
|
||||
<span class="dk">Duration</span> <span class="dv">{formatDuration(selectedPass.duration)}</span>
|
||||
<span class="dk">Max El</span> <span class="dv {q.cls}">{formatEl(selectedPass.maxEl)}</span>
|
||||
<span class="dk">Az Max El</span> <span class="dv">{formatAz(selectedPass.maxElAz)}</span>
|
||||
<span class="dk">AOS Az</span> <span class="dv">{formatAz(selectedPass.aosAz)}</span>
|
||||
<span class="dk">LOS Az</span> <span class="dv">{formatAz(selectedPass.losAz)}</span>
|
||||
<span class="dk">Quality</span> <span class="dv {q.cls}">{q.label}</span>
|
||||
</div>
|
||||
</div>
|
||||
{:else}
|
||||
<div class="empty" style="padding:20px;text-align:center">
|
||||
Select a pass
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<style>
|
||||
.panel { display:flex; height:100%; background:white; font-family:'Inter',sans-serif; overflow:hidden; }
|
||||
|
||||
/* Toolbar */
|
||||
.toolbar { display:flex; flex-direction:column; gap:8px; padding:10px 14px; background:#f8fafc; border-bottom:1px solid #e8edf2; flex-shrink:0; }
|
||||
|
||||
.view-toggle { display:flex; gap:4px; background:#f0f4f8; border-radius:8px; padding:3px; width:fit-content; }
|
||||
.vbtn { background:none; border:none; color:#7a9ab8; font-family:'Inter',sans-serif; font-size:12px; font-weight:500; padding:5px 12px; border-radius:6px; cursor:pointer; transition:all .15s; }
|
||||
.vbtn.on { background:white; color:#1a5a9a; font-weight:700; box-shadow:0 1px 3px rgba(0,0,0,0.1); }
|
||||
|
||||
.el-filters { display:flex; gap:4px; flex-wrap:wrap; }
|
||||
.elfbtn { background:#f0f4f8; border:1.5px solid #e0e8f0; color:#6a8aa8; font-family:'Inter',sans-serif; font-size:11px; font-weight:500; padding:4px 10px; border-radius:20px; cursor:pointer; transition:all .15s; }
|
||||
.elfbtn:hover { border-color:#1a5a9a; color:#1a5a9a; }
|
||||
.elfbtn.on { background:#1a5a9a; border-color:#1a5a9a; color:white; font-weight:700; }
|
||||
|
||||
/* List */
|
||||
.pass-list { flex:1; display:flex; flex-direction:column; border-right:1px solid #e8edf2; overflow:hidden; min-width:0; }
|
||||
.list-hdr { display:flex; justify-content:space-between; align-items:center; padding:8px 14px; background:white; border-bottom:1px solid #e8edf2; flex-shrink:0; }
|
||||
.hdr-title { font-size:11px; font-weight:700; color:#1a5a9a; letter-spacing:.04em; }
|
||||
.hdr-count { font-size:11px; color:#9ab0c8; }
|
||||
.loading { color:#9ab0c8; font-style:italic; font-size:11px; animation:pulse 1.5s infinite; }
|
||||
@keyframes pulse { 0%,100%{opacity:1} 50%{opacity:.4} }
|
||||
|
||||
.empty { padding:40px 20px; text-align:center; color:#9ab0c8; font-size:13px; line-height:1.8; }
|
||||
|
||||
.pass-rows { flex:1; overflow-y:auto; }
|
||||
.pass-rows::-webkit-scrollbar { width:4px; }
|
||||
.pass-rows::-webkit-scrollbar-thumb { background:#d0dce8; border-radius:2px; }
|
||||
|
||||
.pass-row { border-bottom:1px solid #f0f4f8; cursor:pointer; transition:background .1s; }
|
||||
.pass-row:active { background:#e8f0fa; }
|
||||
.pass-row:hover { background:#f8fafc; }
|
||||
.pass-row.selected { background:#f0f6ff; border-left:3px solid #1a5a9a; }
|
||||
.pass-row.active { background:#f0f9f4; border-left:3px solid #1a9a50; }
|
||||
|
||||
.pass-sat { font-size:11px; font-weight:700; color:#1a5a9a; padding:6px 14px 0; }
|
||||
.dbl-hint { font-size:9px; color:#b0c4d8; font-weight:400; margin-left:6px; }
|
||||
|
||||
.pass-body { display:flex; gap:12px; align-items:flex-start; padding:8px 14px; }
|
||||
|
||||
.pass-left { display:flex; flex-direction:column; gap:3px; min-width:110px; }
|
||||
.pass-time { font-size:13px; color:#1a2a3a; font-weight:600; }
|
||||
.countdown { display:flex; align-items:center; gap:5px; font-size:11px; color:#7a9ab8; }
|
||||
.countdown.live { color:#1a9a50; }
|
||||
.countdown.soon { color:#e07030; }
|
||||
.cd-val { font-weight:700; font-size:13px; }
|
||||
.live-dot { width:7px; height:7px; border-radius:50%; background:#1a9a50; box-shadow:0 0 5px #1a9a50; animation:blink 1.2s infinite; flex-shrink:0; }
|
||||
.progress-bar { height:3px; background:#e0f0e8; border-radius:2px; margin-top:4px; }
|
||||
.progress-fill { height:100%; background:#1a5a9a; border-radius:2px; transition:width .5s; }
|
||||
|
||||
.pass-center { display:flex; flex-direction:column; gap:3px; align-items:center; min-width:80px; }
|
||||
.max-el { font-size:15px; font-weight:700; }
|
||||
.duration { font-size:10px; color:#9ab0c8; }
|
||||
.quality-badge { font-size:9px; padding:2px 7px; border-radius:10px; font-weight:700; }
|
||||
|
||||
.q-excellent { color:#1a7a40; }
|
||||
.q-excellent.quality-badge { background:#e0f7ee; }
|
||||
.q-good { color:#1a5a9a; }
|
||||
.q-good.quality-badge { background:#e0eeff; }
|
||||
.q-fair { color:#9a6010; }
|
||||
.q-fair.quality-badge { background:#fff4e0; }
|
||||
.q-poor { color:#c03030; }
|
||||
.q-poor.quality-badge { background:#fff0f0; }
|
||||
|
||||
.pass-right { display:flex; flex-direction:column; gap:2px; margin-left:auto; }
|
||||
.az-row { display:flex; gap:6px; align-items:baseline; }
|
||||
.az-lbl { font-size:9px; color:#9ab0c8; width:24px; font-weight:700; }
|
||||
.az-val { font-size:11px; color:#4a6a8a; }
|
||||
|
||||
/* Detail */
|
||||
.pass-detail { width:300px; flex-shrink:0; display:flex; flex-direction:column; background:#f8fafc; overflow-y:auto; }
|
||||
.pass-detail::-webkit-scrollbar { width:4px; }
|
||||
.pass-detail::-webkit-scrollbar-thumb { background:#d0dce8; }
|
||||
|
||||
.polar-wrap { padding:16px; display:flex; justify-content:center; border-bottom:1px solid #e8edf2; }
|
||||
|
||||
.detail-box { padding:14px 16px; display:flex; flex-direction:column; gap:10px; }
|
||||
.detail-satname { font-size:12px; font-weight:700; color:#1a5a9a; }
|
||||
.detail-title { font-size:12px; color:#4a6a8a; font-weight:600; }
|
||||
|
||||
.detail-countdown { display:flex; align-items:center; gap:8px; background:white; border:1.5px solid #e0e8f0; border-radius:10px; padding:10px 14px; }
|
||||
.detail-countdown.live { border-color:#1a5a9a; background:#f0f6ff; }
|
||||
.detail-countdown.soon { border-color:#e07030; background:#fff8f0; }
|
||||
.cd-label { font-size:10px; color:#9ab0c8; font-weight:600; text-transform:uppercase; letter-spacing:.06em; }
|
||||
.cd-big { font-size:22px; font-weight:700; color:#1a2a3a; margin-left:auto; }
|
||||
.detail-countdown.live .cd-big { color:#1a5a9a; }
|
||||
.detail-countdown.soon .cd-big { color:#e07030; }
|
||||
|
||||
.detail-grid { display:grid; grid-template-columns:auto 1fr; gap:5px 12px; }
|
||||
.dk { font-size:10px; color:#9ab0c8; text-transform:uppercase; letter-spacing:.06em; align-self:center; font-weight:600; }
|
||||
.dv { font-size:12px; color:#1a2a3a; font-weight:600; }
|
||||
|
||||
@keyframes blink { 0%,100%{opacity:1} 50%{opacity:.3} }
|
||||
</style>
|
||||
114
frontend/src/components/PolarPlot.svelte
Normal file
114
frontend/src/components/PolarPlot.svelte
Normal file
@@ -0,0 +1,114 @@
|
||||
<script>
|
||||
export let points = []
|
||||
export let currentAz = null
|
||||
export let currentEl = null
|
||||
export let size = 260
|
||||
export let satName = ''
|
||||
|
||||
const cx = size / 2
|
||||
const cy = size / 2
|
||||
const r = (size / 2) - 20
|
||||
|
||||
function toXY(az, el) {
|
||||
const elC = Math.max(0, Math.min(90, el))
|
||||
const dist = r * (1 - elC / 90)
|
||||
const azRad = (az - 90) * Math.PI / 180
|
||||
return { x: cx + dist * Math.cos(azRad), y: cy + dist * Math.sin(azRad) }
|
||||
}
|
||||
|
||||
$: trackPath = (() => {
|
||||
if (!points || points.length < 2) return ''
|
||||
const vis = points.filter(p => p.el >= 0)
|
||||
if (vis.length < 2) return ''
|
||||
return vis.map((p, i) => {
|
||||
const {x, y} = toXY(p.az, p.el)
|
||||
return (i === 0 ? 'M' : 'L') + `${x.toFixed(1)},${y.toFixed(1)}`
|
||||
}).join(' ')
|
||||
})()
|
||||
|
||||
$: aosPoint = points?.length > 0 ? toXY(points[0].az, Math.max(0, points[0].el)) : null
|
||||
$: losPoint = points?.length > 0 ? toXY(points[points.length-1].az, Math.max(0, points[points.length-1].el)) : null
|
||||
$: currentPos = (currentAz !== null && currentEl !== null && currentEl >= 0) ? toXY(currentAz, currentEl) : null
|
||||
|
||||
const compass = [
|
||||
{ label: 'N', az: 0 }, { label: 'E', az: 90 },
|
||||
{ label: 'S', az: 180 }, { label: 'O', az: 270 },
|
||||
]
|
||||
</script>
|
||||
|
||||
<svg width={size} height={size} viewBox="0 0 {size} {size}" class="polar">
|
||||
<!-- Background -->
|
||||
<circle cx={cx} cy={cy} r={r+18} fill="white" stroke="#e0e8f0" stroke-width="1"/>
|
||||
|
||||
<!-- Elevation rings -->
|
||||
{#each [0, 30, 60, 90] as el}
|
||||
{@const rr = r * (1 - el / 90)}
|
||||
<circle cx={cx} cy={cy} r={rr}
|
||||
fill="none"
|
||||
stroke={el === 0 ? '#c0ccd8' : '#e8edf2'}
|
||||
stroke-width={el === 0 ? 1.5 : 1}
|
||||
stroke-dasharray={el === 0 ? 'none' : '3,4'}
|
||||
/>
|
||||
{#if el > 0 && el < 90}
|
||||
<text x={cx + 4} y={cy - rr + 12} fill="#9ab0c8" font-size="9" font-family="Inter,sans-serif">{el}°</text>
|
||||
{/if}
|
||||
{/each}
|
||||
|
||||
<!-- Azimuth spokes -->
|
||||
{#each [0,45,90,135,180,225,270,315] as az}
|
||||
{@const rad = (az - 90) * Math.PI / 180}
|
||||
<line x1={cx} y1={cy} x2={cx + r*Math.cos(rad)} y2={cy + r*Math.sin(rad)} stroke="#e8edf2" stroke-width="1"/>
|
||||
{/each}
|
||||
|
||||
<!-- Compass labels -->
|
||||
{#each compass as {label, az}}
|
||||
{@const rad = (az - 90) * Math.PI / 180}
|
||||
<text x={cx + (r+13)*Math.cos(rad)} y={cy + (r+13)*Math.sin(rad) + 4}
|
||||
text-anchor="middle" fill="#4a6a8a"
|
||||
font-size="11" font-family="Inter,sans-serif" font-weight="700"
|
||||
>{label}</text>
|
||||
{/each}
|
||||
|
||||
<!-- Track path -->
|
||||
{#if trackPath}
|
||||
<path d={trackPath} fill="none" stroke="#1a5a9a" stroke-width="2" opacity="0.8" stroke-linecap="round"/>
|
||||
{/if}
|
||||
|
||||
<!-- AOS marker -->
|
||||
{#if aosPoint}
|
||||
<circle cx={aosPoint.x} cy={aosPoint.y} r="4" fill="#1a9a50"/>
|
||||
<text x={aosPoint.x+6} y={aosPoint.y+4} fill="#1a9a50" font-size="8" font-family="Inter,sans-serif" font-weight="600">AOS</text>
|
||||
{/if}
|
||||
|
||||
<!-- LOS marker -->
|
||||
{#if losPoint}
|
||||
<circle cx={losPoint.x} cy={losPoint.y} r="4" fill="#e05030"/>
|
||||
<text x={losPoint.x+6} y={losPoint.y+4} fill="#e05030" font-size="8" font-family="Inter,sans-serif" font-weight="600">LOS</text>
|
||||
{/if}
|
||||
|
||||
<!-- Current position -->
|
||||
{#if currentPos}
|
||||
<circle cx={currentPos.x} cy={currentPos.y} r="14" fill="none" stroke="#1a5a9a" stroke-width="1" opacity="0.3">
|
||||
<animate attributeName="r" from="8" to="18" dur="2s" repeatCount="indefinite"/>
|
||||
<animate attributeName="opacity" from="0.5" to="0" dur="2s" repeatCount="indefinite"/>
|
||||
</circle>
|
||||
<circle cx={currentPos.x} cy={currentPos.y} r="5" fill="#1a5a9a"/>
|
||||
<circle cx={currentPos.x} cy={currentPos.y} r="2.5" fill="white"/>
|
||||
{/if}
|
||||
|
||||
<!-- Center -->
|
||||
<circle cx={cx} cy={cy} r="2" fill="#c0ccd8"/>
|
||||
<line x1={cx-5} y1={cy} x2={cx+5} y2={cy} stroke="#c0ccd8" stroke-width="1"/>
|
||||
<line x1={cx} y1={cy-5} x2={cx} y2={cy+5} stroke="#c0ccd8" stroke-width="1"/>
|
||||
|
||||
<!-- Sat name -->
|
||||
{#if satName}
|
||||
<text x={cx} y={size-6} text-anchor="middle" fill="#4a6a8a" font-size="10" font-family="Inter,sans-serif" font-weight="600">
|
||||
{satName.replace(' (ZARYA)','')}
|
||||
</text>
|
||||
{/if}
|
||||
</svg>
|
||||
|
||||
<style>
|
||||
.polar { display:block; border-radius:50%; }
|
||||
</style>
|
||||
248
frontend/src/components/SatSelector.svelte
Normal file
248
frontend/src/components/SatSelector.svelte
Normal file
@@ -0,0 +1,248 @@
|
||||
<script>
|
||||
import { trackedSat, passes, settings, watchlist, trackFreqMode, trackAzimuth, trackedPosition } from '../stores/satstore.js'
|
||||
import { formatRange, formatEl, formatAz, formatFreq } from '../lib/utils.js'
|
||||
import { Go } from '../lib/wails.js'
|
||||
import { getFrequencies, getPrimaryFrequency, getSatType, TYPE_STYLE, getDisplayName } from '../lib/satdb.js'
|
||||
|
||||
// Sync track toggles to Go backend
|
||||
$: Go.SetTrackFreqMode($trackFreqMode)
|
||||
$: Go.SetTrackAzimuth($trackAzimuth)
|
||||
|
||||
let search = ''
|
||||
let loading = false
|
||||
let freqIndex = 0
|
||||
|
||||
$: displayList = [...new Set($watchlist)]
|
||||
.filter(n => !search || n.toLowerCase().includes(search.toLowerCase()) || getDisplayName(n).toLowerCase().includes(search.toLowerCase()))
|
||||
.sort((a, b) => getDisplayName(a).localeCompare(getDisplayName(b)))
|
||||
|
||||
$: satFreqs = getFrequencies($trackedSat)
|
||||
$: selectedFreq = satFreqs?.[freqIndex] || null
|
||||
$: hasFreqs = satFreqs && satFreqs.length > 0
|
||||
// Auto-disable Track Freq/Mode if satellite has no known frequencies
|
||||
$: if (!hasFreqs && $trackFreqMode) trackFreqMode.set(false)
|
||||
$: satDisplayName = getDisplayName($trackedSat)
|
||||
|
||||
async function selectSat(name) {
|
||||
if ($trackedSat === name) return
|
||||
loading = true
|
||||
freqIndex = 0
|
||||
// Reset tracking toggles for new satellite
|
||||
trackFreqMode.set(false)
|
||||
trackAzimuth.set(false)
|
||||
trackedSat.set(name)
|
||||
const ps = await Go.GetPasses(name, 24)
|
||||
passes.set(ps || [])
|
||||
await Go.StartTracking(name)
|
||||
const freq = getPrimaryFrequency(name)
|
||||
if (freq) await applyFrequency(freq)
|
||||
loading = false
|
||||
}
|
||||
|
||||
async function applyFrequency(freq) {
|
||||
const down = freq.downHz || 0
|
||||
const up = freq.upHz || 0
|
||||
// Always update the nominal frequencies for Doppler calculation
|
||||
settings.update(s => ({ ...s, downlinkHz: down, uplinkHz: up }))
|
||||
await Go.SetSatelliteFrequencies(down, up)
|
||||
// Only apply mode to radio if Track Freq/Mode is active
|
||||
if (freq.mode && $trackFreqMode) await Go.SetSatelliteMode(freq.mode)
|
||||
}
|
||||
|
||||
async function stopTracking() {
|
||||
await Go.StopTracking()
|
||||
trackedSat.set('')
|
||||
passes.set([])
|
||||
}
|
||||
</script>
|
||||
|
||||
<div class="sel">
|
||||
<div class="search-bar">
|
||||
<span class="sico">🔍</span>
|
||||
<input type="text" bind:value={search} placeholder="Filter…" class="sinput"/>
|
||||
{#if search}<button class="clr" on:click={()=>search=''}>×</button>{/if}
|
||||
</div>
|
||||
|
||||
{#if $trackedSat}
|
||||
<div class="tracked-bar">
|
||||
<div class="tracked-info">
|
||||
<span class="live-dot"></span>
|
||||
<span class="tname">{satDisplayName || $trackedSat}</span>
|
||||
</div>
|
||||
<button class="stop-btn" on:click={stopTracking}>Stop</button>
|
||||
</div>
|
||||
|
||||
<!-- Track toggles -->
|
||||
<div class="track-row">
|
||||
<button class="track-btn {$trackFreqMode ? 'track-on' : ''} {!hasFreqs ? 'track-disabled' : ''}"
|
||||
on:click={() => hasFreqs && trackFreqMode.update(v => !v)}
|
||||
title={!hasFreqs ? 'No known frequencies for this satellite' : ''}>
|
||||
<span class="track-dot {$trackFreqMode ? 'on' : ''}"></span>
|
||||
Track Freq/Mode
|
||||
</button>
|
||||
<button class="track-btn {$trackAzimuth ? 'track-on' : ''}"
|
||||
on:click={() => trackAzimuth.update(v => !v)}>
|
||||
<span class="track-dot {$trackAzimuth ? 'on' : ''}"></span>
|
||||
Track Azimuth
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{#if satFreqs && satFreqs.length > 0}
|
||||
<div class="freq-section">
|
||||
<div class="freq-title">Frequencies</div>
|
||||
{#each satFreqs as freq, i}
|
||||
<button class="freq-card {freqIndex === i ? 'active' : ''}"
|
||||
on:click={() => { freqIndex = i; applyFrequency(freq) }}>
|
||||
<div class="freq-head">
|
||||
<span class="freq-mode">{freq.mode}</span>
|
||||
{#if freqIndex === i}<span class="freq-check">✓ Active</span>{/if}
|
||||
</div>
|
||||
<div class="freq-rx">↓ {formatFreq(freq.downHz)}</div>
|
||||
{#if freq.upHz > 0}
|
||||
<div class="freq-tx">↑ {formatFreq(freq.upHz)}</div>
|
||||
{:else}
|
||||
<div class="freq-tx muted">Simplex / RX only</div>
|
||||
{/if}
|
||||
{#if freq.notes}<div class="freq-notes">{freq.notes}</div>{/if}
|
||||
</button>
|
||||
{/each}
|
||||
</div>
|
||||
{:else}
|
||||
<div class="no-freq">No known frequencies for this satellite</div>
|
||||
{/if}
|
||||
{/if}
|
||||
|
||||
{#if !$trackedSat && !search}
|
||||
<div class="sec-lbl">FAVORITES</div>
|
||||
<div class="faves">
|
||||
{#each $watchlist.slice(0,8) as f}
|
||||
<button class="fave" on:click={() => selectSat(f)}>
|
||||
{f.replace(' (ZARYA)','').replace(' (FM)','')}
|
||||
</button>
|
||||
{/each}
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<div class="sec-lbl">WATCHLIST ({displayList.length})</div>
|
||||
<div class="sat-list">
|
||||
{#if displayList.length === 0}
|
||||
<div class="empty">Use the 🛰 button on the map to add satellites</div>
|
||||
{:else}
|
||||
{#each displayList as name}
|
||||
<button class="sitem {$trackedSat === name ? 'on' : ''}" on:click={() => selectSat(name)}>
|
||||
<span class="sdot {$trackedSat === name ? 'active' : ''}"></span>
|
||||
<span class="sname">{getDisplayName(name)}</span>
|
||||
{#if getSatType(name)}
|
||||
{@const t = getSatType(name)}
|
||||
{@const s = TYPE_STYLE[t]}
|
||||
<span class="type-badge" style="background:{s.bg};color:{s.color};border-color:{s.border}">{t}</span>
|
||||
{/if}
|
||||
</button>
|
||||
{/each}
|
||||
{/if}
|
||||
</div>
|
||||
<!-- Satellite info panel — always visible when satellite is tracked -->
|
||||
{#if $trackedSat && $trackedPosition}
|
||||
{@const pos = $trackedPosition}
|
||||
<div class="sat-info-panel">
|
||||
<div class="sip-row">
|
||||
<span class="sip-k">AZ</span><span class="sip-v">{pos.az?.toFixed(1)}°</span>
|
||||
<span class="sip-k">EL</span><span class="sip-v {pos.el >= 0 ? 'up' : ''}">{pos.el?.toFixed(1)}°</span>
|
||||
</div>
|
||||
<div class="sip-row">
|
||||
<span class="sip-k">Range</span><span class="sip-v">{formatRange(pos.range)}</span>
|
||||
<span class="sip-k">Alt</span><span class="sip-v">{pos.alt?.toFixed(0)} km</span>
|
||||
</div>
|
||||
<div class="sip-row">
|
||||
<span class="sip-k">Radial</span>
|
||||
<span class="sip-v {pos.rangeRate < 0 ? 'approach' : 'recede'}">
|
||||
{pos.rangeRate < 0 ? '▼' : '▲'} {Math.abs(Math.round(pos.rangeRate * 3600)).toLocaleString('en-US')} km/h
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
<style>
|
||||
.sel { display:flex; flex-direction:column; height:100%; overflow:hidden; background:white; font-family:'Inter',sans-serif; }
|
||||
|
||||
.search-bar { display:flex; align-items:center; gap:8px; padding:10px 12px; border-bottom:1px solid #e8edf2; }
|
||||
.sico { color:#9ab0c8; font-size:13px; }
|
||||
.sinput { flex:1; background:transparent; border:none; color:#1a2a3a; font-family:'Inter',sans-serif; font-size:13px; outline:none; }
|
||||
.sinput::placeholder { color:#b0c4d8; }
|
||||
.clr { background:none; border:none; color:#9ab0c8; cursor:pointer; font-size:16px; padding:0; }
|
||||
|
||||
.tracked-bar { display:flex; align-items:center; justify-content:space-between; padding:8px 12px; background:#f0f6ff; border-bottom:1px solid #d0e0f0; }
|
||||
.tracked-info { display:flex; align-items:center; gap:7px; }
|
||||
.live-dot { width:8px; height:8px; border-radius:50%; background:#1a5a9a; box-shadow:0 0 6px rgba(26,90,154,0.5); animation:blink 1.5s infinite; flex-shrink:0; }
|
||||
.tname { font-size:12px; font-weight:600; color:#1a5a9a; overflow:hidden; text-overflow:ellipsis; white-space:nowrap; }
|
||||
.stop-btn { background:none; border:1px solid #e0a0a0; color:#c03030; font-size:11px; padding:3px 8px; cursor:pointer; border-radius:5px; font-family:'Inter',sans-serif; }
|
||||
.stop-btn:hover { background:#fff0f0; }
|
||||
|
||||
.freq-section { padding:10px 12px; border-bottom:1px solid #e8edf2; display:flex; flex-direction:column; gap:6px; }
|
||||
.freq-title { font-size:10px; font-weight:600; color:#9ab0c8; text-transform:uppercase; letter-spacing:.08em; }
|
||||
.freq-card { background:#f8fafc; border:1.5px solid #e0e8f0; border-radius:8px; padding:8px 10px; cursor:pointer; text-align:left; display:flex; flex-direction:column; gap:2px; transition:all .15s; font-family:'Inter',sans-serif; }
|
||||
.freq-card:hover { border-color:#5a9ad5; background:#f0f6ff; }
|
||||
.freq-card.active { border-color:#1a5a9a; background:#e8f0fa; }
|
||||
.freq-head { display:flex; justify-content:space-between; align-items:center; }
|
||||
.freq-mode { font-size:12px; font-weight:700; color:#1a2a3a; }
|
||||
.freq-check { font-size:10px; color:#1a7a40; font-weight:600; }
|
||||
.freq-rx { font-size:13px; font-weight:700; color:#1a5a9a; }
|
||||
.freq-tx { font-size:12px; color:#e07030; }
|
||||
.freq-tx.muted { color:#9ab0c8; font-style:italic; }
|
||||
.freq-notes { font-size:10px; color:#9ab0c8; }
|
||||
|
||||
.no-freq { padding:10px 12px; font-size:11px; color:#9ab0c8; font-style:italic; border-bottom:1px solid #e8edf2; }
|
||||
|
||||
.sec-lbl { font-size:10px; font-weight:600; color:#9ab0c8; letter-spacing:.08em; padding:8px 12px 4px; text-transform:uppercase; }
|
||||
|
||||
.faves { display:flex; flex-wrap:wrap; gap:4px; padding:4px 12px 8px; }
|
||||
.fave { background:#f0f4f8; border:1px solid #d0dce8; color:#4a7a9a; font-family:'Inter',sans-serif; font-size:11px; font-weight:500; padding:3px 8px; border-radius:5px; cursor:pointer; transition:all .15s; }
|
||||
.fave:hover { background:#1a5a9a; color:white; border-color:#1a5a9a; }
|
||||
|
||||
.sat-list { flex:1; overflow-y:auto; }
|
||||
.sat-list::-webkit-scrollbar { width:3px; }
|
||||
.sat-list::-webkit-scrollbar-thumb { background:#d0dce8; border-radius:2px; }
|
||||
|
||||
.sitem { display:flex; align-items:center; gap:8px; width:100%; background:none; border:none; border-bottom:1px solid #f0f4f8; color:#4a6a8a; font-family:'Inter',sans-serif; font-size:12px; padding:8px 12px; text-align:left; cursor:pointer; transition:background .1s; }
|
||||
.sitem:hover { background:#f5f8fc; color:#1a2a3a; }
|
||||
.sitem.on { background:#e8f0fa; color:#1a5a9a; font-weight:600; border-left:3px solid #1a5a9a; }
|
||||
|
||||
.sdot { width:9px; height:9px; border-radius:50%; background:#d0dce8; flex-shrink:0; }
|
||||
.sdot.active { background:#1a9a50; box-shadow:0 0 6px #1a9a50; }
|
||||
.sname { flex:1; overflow:hidden; text-overflow:ellipsis; white-space:nowrap; }
|
||||
.type-badge {
|
||||
font-size:9px; font-weight:700; padding:1px 6px;
|
||||
border-radius:10px; border:1px solid; white-space:nowrap;
|
||||
flex-shrink:0; letter-spacing:.02em;
|
||||
}
|
||||
.empty { padding:20px 12px; text-align:center; color:#9ab0c8; font-size:11px; line-height:1.7; }
|
||||
|
||||
@keyframes blink { 0%,100%{opacity:1} 50%{opacity:.3} }
|
||||
|
||||
.sat-info-panel {
|
||||
border-top:1px solid #e8edf2; background:#f8fafc;
|
||||
padding:8px 12px; display:flex; flex-direction:column; gap:4px;
|
||||
flex-shrink:0;
|
||||
}
|
||||
.sip-row { display:flex; align-items:center; gap:8px; flex-wrap:wrap; }
|
||||
.sip-k { font-size:10px; font-weight:600; color:#9ab0c8; text-transform:uppercase; letter-spacing:.06em; min-width:36px; }
|
||||
.sip-v { font-size:12px; font-weight:700; color:#1a2a3a; flex:1; }
|
||||
.sip-v.up { color:#1a9a50; }
|
||||
.sip-v.approach { color:#1a9a50; }
|
||||
.sip-v.recede { color:#1a5a9a; }
|
||||
|
||||
.track-row { display:flex; gap:6px; padding:8px 12px; border-bottom:1px solid #e8edf2; }
|
||||
.track-btn {
|
||||
flex:1; display:flex; align-items:center; gap:6px;
|
||||
background:#f8fafc; border:1.5px solid #e0e8f0;
|
||||
color:#7a9ab8; font-family:'Inter',sans-serif;
|
||||
font-size:10px; font-weight:600; padding:5px 8px;
|
||||
border-radius:6px; cursor:pointer; transition:all .15s;
|
||||
}
|
||||
.track-btn:hover:not(.track-disabled) { border-color:#1a5a9a; color:#1a5a9a; }
|
||||
:global(.track-disabled) { opacity:0.4 !important; cursor:not-allowed !important; }
|
||||
.track-btn.track-on { background:#e8f0fa; border-color:#1a5a9a; color:#1a5a9a; }
|
||||
.track-dot { width:6px; height:6px; border-radius:50%; background:#d0dce8; flex-shrink:0; transition:all .15s; }
|
||||
.track-dot.on { background:#1a5a9a; box-shadow:0 0 4px rgba(26,90,154,0.5); animation:blink 1.5s infinite; }
|
||||
</style>
|
||||
387
frontend/src/components/SettingsPanel.svelte
Normal file
387
frontend/src/components/SettingsPanel.svelte
Normal file
@@ -0,0 +1,387 @@
|
||||
<script>
|
||||
import { settings, flexConnected, rotorConnected, soundEnabled } from '../stores/satstore.js'
|
||||
import { Go } from '../lib/wails.js'
|
||||
import { maidenheadToLatLon, latLonToMaidenhead } from '../lib/maidenhead.js'
|
||||
|
||||
let saving = false
|
||||
let flexStatus = ''
|
||||
let rotorStatus = ''
|
||||
let tleStatus = ''
|
||||
let qraError = ''
|
||||
let qraInput = ''
|
||||
let rxSlice = 0 // Slice A = RX downlink
|
||||
let txSlice = 1 // Slice B = TX uplink
|
||||
|
||||
// Voice selection
|
||||
let availableVoices = []
|
||||
let selectedVoiceName = ''
|
||||
|
||||
// Load slice config on mount
|
||||
import { onMount } from 'svelte'
|
||||
onMount(async () => {
|
||||
// Always load from persisted settings
|
||||
rxSlice = $settings.rxSlice ?? 0
|
||||
txSlice = $settings.txSlice ?? 1
|
||||
|
||||
// Load available TTS voices
|
||||
const loadVoices = () => {
|
||||
const voices = window.speechSynthesis?.getVoices() || []
|
||||
availableVoices = voices.filter(v => v.lang.startsWith('en'))
|
||||
// Also include all voices if no English ones found
|
||||
if (availableVoices.length === 0) availableVoices = voices
|
||||
selectedVoiceName = $settings.ttsVoiceName || ''
|
||||
}
|
||||
loadVoices()
|
||||
// Chrome/Wails loads voices async
|
||||
if (window.speechSynthesis) {
|
||||
window.speechSynthesis.onvoiceschanged = loadVoices
|
||||
}
|
||||
// Apply to Go if already connected
|
||||
if ($flexConnected) {
|
||||
await Go.SetSliceConfig(rxSlice, txSlice)
|
||||
}
|
||||
})
|
||||
|
||||
async function applySlices() {
|
||||
await Go.SetSliceConfig(rxSlice, txSlice)
|
||||
settings.update(s => ({ ...s, rxSlice, txSlice }))
|
||||
flexStatus = `Slices: RX=Slice ${'ABCDEFGH'[rxSlice]}, TX=Slice ${'ABCDEFGH'[txSlice]}`
|
||||
}
|
||||
|
||||
function saveVoice() {
|
||||
settings.update(s => ({ ...s, ttsVoiceName: selectedVoiceName }))
|
||||
}
|
||||
|
||||
// Load once - do NOT subscribe reactively (would overwrite user edits)
|
||||
let local = { ...$settings }
|
||||
|
||||
$: qraFromCoords = (local.qthLat && local.qthLon)
|
||||
? latLonToMaidenhead(local.qthLat, local.qthLon)
|
||||
: ''
|
||||
|
||||
function applyQRA() {
|
||||
qraError = ''
|
||||
const result = maidenheadToLatLon(qraInput)
|
||||
if (!result) { qraError = 'Invalid locator (e.g. JN03ef)'; return }
|
||||
local.qthLat = result.lat
|
||||
local.qthLon = result.lon
|
||||
}
|
||||
|
||||
async function saveQTH() {
|
||||
saving = true
|
||||
settings.update(s => ({ ...s, qthLat: local.qthLat, qthLon: local.qthLon, qthAlt: local.qthAlt }))
|
||||
await Go.SetObserverLocation(local.qthLat, local.qthLon, local.qthAlt)
|
||||
saving = false
|
||||
}
|
||||
|
||||
async function connectFlex() {
|
||||
flexStatus = 'Connecting…'
|
||||
// Persist host/port before connecting
|
||||
settings.update(s => ({ ...s, flexHost: local.flexHost, flexPort: local.flexPort }))
|
||||
const res = await Go.ConnectFlexRadio(local.flexHost, local.flexPort)
|
||||
if (res === 'OK') {
|
||||
flexConnected.set(true)
|
||||
flexStatus = 'Connected ✓'
|
||||
await Go.SetSatelliteFrequencies(local.downlinkHz, local.uplinkHz)
|
||||
} else { flexStatus = res || 'Error' }
|
||||
}
|
||||
|
||||
async function disconnectFlex() {
|
||||
await Go.DisconnectFlexRadio()
|
||||
flexConnected.set(false)
|
||||
flexStatus = 'Disconnected'
|
||||
}
|
||||
|
||||
async function connectRotor() {
|
||||
rotorStatus = 'Connecting…'
|
||||
settings.update(s => ({ ...s, rotorHost: local.rotorHost, rotorPort: local.rotorPort, rotorAzOnly: local.rotorAzOnly }))
|
||||
const res = await Go.ConnectRotor(local.rotorHost, local.rotorPort)
|
||||
if (res === 'OK') {
|
||||
rotorConnected.set(true); rotorStatus = '✓ Connected'
|
||||
await Go.SetRotorAzOnly(local.rotorAzOnly ?? true)
|
||||
}
|
||||
else { rotorStatus = res || 'Error' }
|
||||
}
|
||||
|
||||
async function disconnectRotor() {
|
||||
await Go.DisconnectRotor()
|
||||
rotorConnected.set(false)
|
||||
rotorStatus = 'Disconnected'
|
||||
}
|
||||
|
||||
async function refreshTLE() {
|
||||
tleStatus = 'Downloading…'
|
||||
const res = await Go.RefreshTLE()
|
||||
tleStatus = res === 'OK' ? 'Updated ✓' : res
|
||||
setTimeout(() => tleStatus = '', 3000)
|
||||
}
|
||||
|
||||
function toggleAutoFlex() {
|
||||
local.autoConnectFlex = !local.autoConnectFlex
|
||||
settings.update(s => ({ ...s, autoConnectFlex: local.autoConnectFlex }))
|
||||
}
|
||||
|
||||
function toggleAutoRotor() {
|
||||
local.autoConnectRotor = !local.autoConnectRotor
|
||||
settings.update(s => ({ ...s, autoConnectRotor: local.autoConnectRotor }))
|
||||
}
|
||||
</script>
|
||||
|
||||
<div class="sp">
|
||||
<div class="sp-scroll">
|
||||
|
||||
<!-- QTH -->
|
||||
<section class="card">
|
||||
<h3>📍 QTH / Locator</h3>
|
||||
<div class="qra-row">
|
||||
<div class="field-col">
|
||||
<label>Maidenhead Locator</label>
|
||||
<div class="inp-btn">
|
||||
<input type="text" bind:value={qraInput} placeholder="JN03ef"
|
||||
maxlength="8" class="mono upper"
|
||||
on:keydown={e => e.key==='Enter' && applyQRA()} />
|
||||
<button class="btn-sm" on:click={applyQRA}>Apply</button>
|
||||
</div>
|
||||
{#if qraError}<span class="err">{qraError}</span>{/if}
|
||||
</div>
|
||||
<div class="qra-display">
|
||||
<label>Current QRA</label>
|
||||
<span class="qra-big">{qraFromCoords || '—'}</span>
|
||||
</div>
|
||||
</div>
|
||||
<div class="sep">or enter coordinates manually</div>
|
||||
<div class="fg">
|
||||
<label>Latitude</label>
|
||||
<input type="number" bind:value={local.qthLat} step="0.0001" min="-90" max="90" />
|
||||
<label>Longitude</label>
|
||||
<input type="number" bind:value={local.qthLon} step="0.0001" min="-180" max="180" />
|
||||
<label>Altitude (m)</label>
|
||||
<input type="number" bind:value={local.qthAlt} step="1" min="0" />
|
||||
</div>
|
||||
<button class="btn-primary" on:click={saveQTH} disabled={saving}>
|
||||
{saving ? 'Saving…' : '💾 Save QTH'}
|
||||
</button>
|
||||
</section>
|
||||
|
||||
<!-- FlexRadio -->
|
||||
<section class="card">
|
||||
<div class="card-head">
|
||||
<h3>🔌 FlexRadio</h3>
|
||||
<span class="badge {$flexConnected ? 'ok' : 'off'}">{$flexConnected ? 'CONNECTED' : 'OFFLINE'}</span>
|
||||
</div>
|
||||
<div class="fg">
|
||||
<label>Host / IP</label>
|
||||
<input type="text" bind:value={local.flexHost} placeholder="192.168.1.x" />
|
||||
<label>TCP Port</label>
|
||||
<input type="number" bind:value={local.flexPort} />
|
||||
</div>
|
||||
<div class="toggle-row">
|
||||
<button class="toggle {local.autoConnectFlex ? 'toggle-on' : ''}" on:click={toggleAutoFlex}>
|
||||
{local.autoConnectFlex ? '✓' : '○'} Auto-connect on startup
|
||||
</button>
|
||||
</div>
|
||||
<div class="btn-row">
|
||||
{#if $flexConnected}
|
||||
<button class="btn-danger" on:click={disconnectFlex}>Disconnect</button>
|
||||
{:else}
|
||||
<button class="btn-primary" on:click={connectFlex}>Connect</button>
|
||||
{/if}
|
||||
{#if flexStatus}
|
||||
<span class="status {flexStatus.includes('✓') || flexStatus.includes('Slice') ? 'ok-txt' : 'err-txt'}">{flexStatus}</span>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
<!-- Satellite Slice Configuration -->
|
||||
<div class="slice-config">
|
||||
<div class="slice-label">SATELLITE SLICES</div>
|
||||
<div class="slice-row">
|
||||
<span class="slice-lbl">↓ RX Downlink</span>
|
||||
<select bind:value={rxSlice} class="slice-sel">
|
||||
{#each ['A','B','C','D'] as l, i}
|
||||
<option value={i}>Slice {l}</option>
|
||||
{/each}
|
||||
</select>
|
||||
</div>
|
||||
<div class="slice-row">
|
||||
<span class="slice-lbl">↑ TX Uplink</span>
|
||||
<select bind:value={txSlice} class="slice-sel">
|
||||
{#each ['A','B','C','D'] as l, i}
|
||||
<option value={i}>Slice {l}</option>
|
||||
{/each}
|
||||
</select>
|
||||
</div>
|
||||
<button class="btn-secondary btn-sm" on:click={applySlices}>Apply slices</button>
|
||||
<p class="hint">Default: Slice A = RX downlink, Slice B = TX uplink</p>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<!-- PstRotator -->
|
||||
<section class="card">
|
||||
<div class="card-head">
|
||||
<h3>🔄 PstRotator</h3>
|
||||
<span class="badge {$rotorConnected ? 'ok' : 'off'}">{$rotorConnected ? 'CONNECTED' : 'OFFLINE'}</span>
|
||||
</div>
|
||||
<div class="fg">
|
||||
<label>Host / IP</label>
|
||||
<input type="text" bind:value={local.rotorHost} placeholder="127.0.0.1" />
|
||||
<label>UDP Port</label>
|
||||
<input type="number" bind:value={local.rotorPort} />
|
||||
</div>
|
||||
<div class="toggle-row">
|
||||
<button class="toggle {local.autoConnectRotor ? 'toggle-on' : ''}" on:click={toggleAutoRotor}>
|
||||
{local.autoConnectRotor ? '✓' : '○'} Auto-connect on startup
|
||||
</button>
|
||||
</div>
|
||||
<div class="btn-row">
|
||||
{#if $rotorConnected}
|
||||
<button class="btn-danger" on:click={disconnectRotor}>Disconnect</button>
|
||||
{:else}
|
||||
<button class="btn-primary" on:click={connectRotor}>Connect</button>
|
||||
{/if}
|
||||
{#if rotorStatus}
|
||||
<span class="status {rotorStatus.includes('✓') ? 'ok-txt' : 'err-txt'}">{rotorStatus}</span>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
<div class="slice-config">
|
||||
<div class="slice-label">ROTOR MODE</div>
|
||||
<div class="slice-row">
|
||||
<span class="slice-lbl">Axes</span>
|
||||
<select bind:value={local.rotorAzOnly} class="slice-sel" on:change={() => Go.SetRotorAzOnly(local.rotorAzOnly ?? true)}>
|
||||
<option value={true}>Azimuth only</option>
|
||||
<option value={false}>Azimuth + Elevation</option>
|
||||
</select>
|
||||
</div>
|
||||
<p class="hint">Select "Azimuth only" if you don't have an elevation rotor</p>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<!-- Voice Alerts -->
|
||||
<section class="card">
|
||||
<div class="card-head">
|
||||
<h3>🔊 Voice Alerts</h3>
|
||||
<button class="toggle {$soundEnabled ? 'toggle-on' : ''}" on:click={() => soundEnabled.update(v => !v)}>
|
||||
{$soundEnabled ? '✓ Enabled' : '○ Disabled'}
|
||||
</button>
|
||||
</div>
|
||||
<div class="fg">
|
||||
<label>TTS Voice</label>
|
||||
<select bind:value={selectedVoiceName} on:change={saveVoice} disabled={!$soundEnabled}>
|
||||
<option value="">— System default —</option>
|
||||
{#each availableVoices as v}
|
||||
<option value={v.name}>{v.name} ({v.lang})</option>
|
||||
{/each}
|
||||
</select>
|
||||
</div>
|
||||
{#if availableVoices.length === 0}
|
||||
<p class="hint">No voices detected. Install English voices via Windows Settings → Speech.</p>
|
||||
{:else}
|
||||
<p class="hint">{availableVoices.length} English voice{availableVoices.length > 1 ? 's' : ''} available. Recommended: Microsoft David or Zira.</p>
|
||||
{/if}
|
||||
<div class="btn-row">
|
||||
<button class="btn-secondary btn-sm" disabled={!$soundEnabled} on:click={() => {
|
||||
if (!window.speechSynthesis) return
|
||||
const utt = new SpeechSynthesisUtterance('AOS ISS is rising. Pass will last for 8 minutes.')
|
||||
utt.lang = 'en-US'
|
||||
if (selectedVoiceName) {
|
||||
const v = availableVoices.find(v => v.name === selectedVoiceName)
|
||||
if (v) utt.voice = v
|
||||
}
|
||||
window.speechSynthesis.cancel()
|
||||
window.speechSynthesis.speak(utt)
|
||||
}}>▶ Test voice</button>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<!-- TLE -->
|
||||
<section class="card">
|
||||
<h3>🛰️ TLE Data</h3>
|
||||
<p class="hint">Source: Celestrak amateur · Fallback: PE0SAT</p>
|
||||
<div class="btn-row">
|
||||
<button class="btn-secondary" on:click={refreshTLE}>↻ Refresh TLE</button>
|
||||
{#if tleStatus}
|
||||
<span class="status {tleStatus.includes('✓') ? 'ok-txt' : 'err-txt'}">{tleStatus}</span>
|
||||
{/if}
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<!-- About -->
|
||||
<section class="card">
|
||||
<h3>ℹ️ About</h3>
|
||||
<p><strong>SatMaster v1.0</strong> — F4BPO</p>
|
||||
<p>Amateur satellite tracking · FlexRadio Doppler correction · PstRotator control</p>
|
||||
<p class="hint">SGP4 via akhenakh/sgp4 · Go + Wails + Svelte</p>
|
||||
</section>
|
||||
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<style>
|
||||
.sp { height:100%; background:#f0f2f5; overflow:hidden; font-family:'Inter',sans-serif; }
|
||||
.sp-scroll { height:100%; overflow-y:auto; padding:16px 20px; display:flex; flex-direction:column; gap:14px; max-width:680px; margin:0 auto; }
|
||||
.sp-scroll::-webkit-scrollbar { width:5px; }
|
||||
.sp-scroll::-webkit-scrollbar-thumb { background:#c0ccd8; border-radius:3px; }
|
||||
|
||||
.card { background:white; border:1px solid #e0e8f0; border-radius:12px; padding:16px 18px; display:flex; flex-direction:column; gap:10px; box-shadow:0 1px 3px rgba(0,0,0,0.05); }
|
||||
.card-head { display:flex; align-items:center; justify-content:space-between; }
|
||||
|
||||
h3 { font-size:14px; font-weight:600; color:#1a2a3a; margin:0; }
|
||||
|
||||
.badge { font-size:10px; font-weight:700; padding:3px 10px; border-radius:20px; letter-spacing:.04em; }
|
||||
.badge.ok { background:#e0f7ee; color:#1a7a40; }
|
||||
.badge.off { background:#fef0f0; color:#c03030; }
|
||||
|
||||
.qra-row { display:flex; gap:16px; align-items:flex-start; }
|
||||
.field-col { flex:1; display:flex; flex-direction:column; gap:5px; }
|
||||
.inp-btn { display:flex; gap:6px; }
|
||||
.qra-display { display:flex; flex-direction:column; gap:4px; min-width:100px; }
|
||||
.qra-big { font-size:22px; font-weight:700; color:#1a5a9a; font-family:'Inter',sans-serif; letter-spacing:.1em; }
|
||||
.mono { font-family:'Inter',sans-serif; letter-spacing:.08em; text-transform:uppercase; }
|
||||
.err { font-size:11px; color:#c03030; }
|
||||
.sep { font-size:11px; color:#9ab0c8; text-align:center; padding:4px 0; border-top:1px solid #e8edf2; margin-top:2px; }
|
||||
|
||||
.fg { display:grid; grid-template-columns:130px 1fr; gap:8px 12px; align-items:center; }
|
||||
label { font-size:12px; color:#5a7a9a; font-weight:500; }
|
||||
|
||||
input, select {
|
||||
background:#f8fafc; border:1.5px solid #d0dce8; color:#1a2a3a;
|
||||
padding:8px 10px; border-radius:8px; font-size:13px;
|
||||
font-family:'Inter',sans-serif; outline:none; width:100%;
|
||||
transition:border-color .15s, box-shadow .15s;
|
||||
}
|
||||
input:focus, select:focus { border-color:#1a5a9a; box-shadow:0 0 0 3px rgba(26,90,154,0.1); }
|
||||
|
||||
.freq-row { display:flex; gap:8px; align-items:center; }
|
||||
.freq-tag { font-size:12px; color:#1a5a9a; font-weight:700; white-space:nowrap; min-width:90px; font-family:'Inter',sans-serif; }
|
||||
|
||||
.toggle-row { display:flex; }
|
||||
.toggle { background:#f8fafc; border:1.5px solid #d0dce8; color:#5a7a9a; font-size:12px; padding:7px 12px; border-radius:8px; cursor:pointer; font-family:'Inter',sans-serif; transition:all .15s; }
|
||||
.toggle:hover { border-color:#1a5a9a; }
|
||||
.toggle.toggle-on { background:#e8f0fa; border-color:#1a5a9a; color:#1a5a9a; font-weight:600; }
|
||||
|
||||
.btn-row { display:flex; align-items:center; gap:10px; flex-wrap:wrap; }
|
||||
button { padding:8px 18px; border-radius:8px; font-size:13px; font-weight:600; cursor:pointer; border:none; transition:all .15s; font-family:'Inter',sans-serif; }
|
||||
button:disabled { opacity:.4; cursor:not-allowed; }
|
||||
button:hover:not(:disabled) { filter:brightness(.95); }
|
||||
.btn-sm { padding:6px 12px; font-size:12px; }
|
||||
|
||||
.btn-primary { background:#1a5a9a; color:white; }
|
||||
.btn-secondary { background:#f0f4f8; color:#1a5a9a; border:1.5px solid #d0dce8; }
|
||||
.btn-apply { background:#f0f4f8; color:#1a5a9a; border:1.5px solid #d0dce8; padding:7px 12px; }
|
||||
.btn-danger { background:#fff0f0; color:#c03030; border:1.5px solid #f0c0c0; }
|
||||
|
||||
.status { font-size:12px; font-weight:600; }
|
||||
.ok-txt { color:#1a7a40; }
|
||||
.err-txt { color:#c03030; }
|
||||
|
||||
.hint { font-size:11px; color:#9ab0c8; }
|
||||
p { font-size:13px; color:#5a7a9a; line-height:1.6; }
|
||||
strong { color:#1a2a3a; }
|
||||
|
||||
/* Slice config */
|
||||
.slice-config { display:flex; flex-direction:column; gap:8px; padding:12px; background:#f8fafc; border:1.5px solid #e0e8f0; border-radius:8px; }
|
||||
.slice-label { font-size:10px; font-weight:600; color:#9ab0c8; text-transform:uppercase; letter-spacing:.08em; }
|
||||
.slice-row { display:flex; align-items:center; gap:10px; }
|
||||
.slice-lbl { font-size:12px; color:#5a7a9a; font-weight:500; width:110px; }
|
||||
.slice-sel { background:#f8fafc; border:1.5px solid #d0dce8; color:#1a2a3a; padding:6px 10px; border-radius:6px; font-family:'Inter',sans-serif; font-size:12px; outline:none; cursor:pointer; width:auto; }
|
||||
</style>
|
||||
165
frontend/src/components/StatusBar.svelte
Normal file
165
frontend/src/components/StatusBar.svelte
Normal file
@@ -0,0 +1,165 @@
|
||||
<script>
|
||||
import { trackedSat, trackedPosition, flexConnected, rotorConnected, tleAge, settings, dopplerEnabled, rotorEnabled, soundEnabled } from '../stores/satstore.js'
|
||||
import { formatFreq, formatShift, formatRange, computeDopplerShift } from '../lib/utils.js'
|
||||
import { onMount, onDestroy } from 'svelte'
|
||||
|
||||
let utcTime = ''
|
||||
let interval
|
||||
|
||||
$: pos = $trackedPosition
|
||||
$: downShift = pos ? computeDopplerShift($settings.downlinkHz, pos.rangeRate) : 0
|
||||
$: upShift = pos ? computeDopplerShift($settings.uplinkHz, -pos.rangeRate) : 0
|
||||
$: corrDown = $settings.downlinkHz + downShift
|
||||
$: corrUp = $settings.uplinkHz + upShift
|
||||
|
||||
onMount(() => { interval = setInterval(() => { utcTime = new Date().toUTCString().slice(17, 25) }, 500) })
|
||||
onDestroy(() => clearInterval(interval))
|
||||
</script>
|
||||
|
||||
<header class="sb">
|
||||
<!-- Logo/clock -->
|
||||
<div class="sb-left">
|
||||
<span class="utc-label">UTC</span>
|
||||
<span class="utc-time">{utcTime}</span>
|
||||
</div>
|
||||
|
||||
<!-- Sat info -->
|
||||
<div class="sb-sat">
|
||||
{#if $trackedSat}
|
||||
<span class="sat-name">{$trackedSat}</span>
|
||||
<span class="sat-sep">|</span>
|
||||
<span class="sat-data">AZ <b>{pos ? pos.az.toFixed(1)+'°' : '—'}</b></span>
|
||||
<span class="sat-data {pos?.el >= 0 ? 'el-up' : ''}">EL <b>{pos ? pos.el.toFixed(1)+'°' : '—'}</b></span>
|
||||
<span class="sat-sep">|</span>
|
||||
<span class="sat-data">Distance <b>{pos ? formatRange(pos.range) : '—'}</b></span>
|
||||
<span class="sat-data">Alt <b>{pos ? pos.alt.toFixed(0)+' km' : '—'}</b></span>
|
||||
<span class="sat-sep">|</span>
|
||||
<span class="sat-data {pos?.rangeRate < 0 ? 'approaching' : 'receding'}">
|
||||
{pos?.rangeRate < 0 ? '▼' : '▲'} <b>{pos ? Math.abs(Math.round(pos.rangeRate*3600)).toLocaleString('en-US')+' km/h' : '—'}</b>
|
||||
</span>
|
||||
{:else}
|
||||
<span class="no-sat">No satellite selected</span>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
<!-- Doppler + connections -->
|
||||
<div class="sb-right">
|
||||
{#if $trackedSat && pos}
|
||||
<div class="doppler-box">
|
||||
<span class="d-label">↓ RX</span>
|
||||
<span class="d-freq">{formatFreq(corrDown)}</span>
|
||||
<span class="d-shift {downShift < 0 ? 'dpos' : 'dneg'}">{formatShift(downShift)}</span>
|
||||
{#if $settings.uplinkHz > 0}
|
||||
<span class="d-sep">|</span>
|
||||
<span class="d-label">↑ TX</span>
|
||||
<span class="d-freq">{formatFreq(corrUp)}</span>
|
||||
<span class="d-shift {upShift > 0 ? 'dpos' : 'dneg'}">{formatShift(upShift)}</span>
|
||||
{/if}
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
{#if $flexConnected}
|
||||
<button class="doppler-toggle {$dopplerEnabled ? 'don' : 'doff'}" on:click={() => dopplerEnabled.update(v => !v)} title={$dopplerEnabled ? 'Doppler ON — click to disable' : 'Doppler OFF — click to enable'}>
|
||||
<span>⟳ DOPPLER {$dopplerEnabled ? 'ON' : 'OFF'}</span>
|
||||
</button>
|
||||
{/if}
|
||||
{#if $rotorConnected}
|
||||
<button class="doppler-toggle {$rotorEnabled ? 'don' : 'doff'}" on:click={() => rotorEnabled.update(v => !v)} title={$rotorEnabled ? 'Rotator tracking ON — click to disable' : 'Rotator tracking OFF — click to enable'}>
|
||||
<span>⤢ ROTATOR {$rotorEnabled ? 'ON' : 'OFF'}</span>
|
||||
</button>
|
||||
{/if}
|
||||
<button class="doppler-toggle {$soundEnabled ? 'don' : 'doff'}" on:click={() => soundEnabled.update(v => !v)} title={$soundEnabled ? 'AOS voice alerts ON — click to disable' : 'AOS voice alerts OFF — click to enable'}>
|
||||
<span>🔊 SOUND {$soundEnabled ? 'ON' : 'OFF'}</span>
|
||||
</button>
|
||||
<div class="conn-group">
|
||||
<div class="conn {$flexConnected ? 'on' : 'off'}">
|
||||
<span class="conn-dot"></span>
|
||||
<span>FLEX</span>
|
||||
</div>
|
||||
<div class="conn {$rotorConnected ? 'on' : 'off'}">
|
||||
<span class="conn-dot"></span>
|
||||
<span>ROTOR</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="tle-info {$tleAge > 48 ? 'stale' : 'fresh'}">
|
||||
TLE {$tleAge !== null ? ($tleAge < 1 ? '<1h' : Math.floor($tleAge)+'h') : '…'}
|
||||
</div>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<style>
|
||||
.sb {
|
||||
display:flex; align-items:center;
|
||||
height:46px; padding:0 14px;
|
||||
background:white;
|
||||
border-bottom:1px solid #e0e8f0;
|
||||
box-shadow:0 1px 4px rgba(0,0,0,0.06);
|
||||
flex-shrink:0; gap:16px;
|
||||
font-family:'Inter',system-ui,sans-serif;
|
||||
}
|
||||
|
||||
.sb-left { display:flex; align-items:baseline; gap:6px; flex-shrink:0; }
|
||||
.utc-label { font-size:10px; color:#9ab0c8; font-weight:600; letter-spacing:.08em; }
|
||||
.utc-time { font-size:14px; font-weight:700; color:#1a2a3a; font-family:'Inter',sans-serif; letter-spacing:-.02em; }
|
||||
|
||||
.sb-sat { display:flex; align-items:center; gap:8px; flex:1; min-width:0; flex-wrap:wrap; }
|
||||
.sat-name { font-size:14px; font-weight:700; color:#1a5a9a; }
|
||||
.sat-sep { color:#d0dce8; }
|
||||
.sat-data { font-size:12px; color:#5a7a9a; }
|
||||
.sat-data b { color:#1a2a3a; }
|
||||
.el-up b { color:#1a9a50 !important; }
|
||||
.approaching b { color:#1a9a50 !important; }
|
||||
.receding b { color:#1a5a9a !important; }
|
||||
.no-sat { font-size:12px; color:#9ab0c8; }
|
||||
|
||||
.sb-right { display:flex; align-items:center; gap:12px; flex-shrink:0; }
|
||||
|
||||
.doppler-box {
|
||||
display:flex; align-items:center; gap:6px;
|
||||
background:#f0f6ff; border:1px solid #c0d8f0;
|
||||
border-radius:8px; padding:4px 10px;
|
||||
font-size:11px;
|
||||
}
|
||||
.d-label { color:#6a9ab8; font-weight:600; }
|
||||
.d-freq { color:#1a2a3a; font-weight:700; font-family:'Inter',sans-serif; }
|
||||
.d-shift { font-weight:700; font-family:'Inter',sans-serif; font-size:10px; }
|
||||
.d-sep { color:#c0d0e0; }
|
||||
.dpos { color:#1a9a50; }
|
||||
.dneg { color:#e07a20; }
|
||||
|
||||
.conn-group { display:flex; gap:6px; }
|
||||
.conn {
|
||||
display:flex; align-items:center; gap:4px;
|
||||
font-size:10px; font-weight:600; letter-spacing:.04em;
|
||||
padding:3px 8px; border-radius:20px;
|
||||
}
|
||||
.conn.on { background:#e0f7ee; color:#1a7a40; }
|
||||
.conn.off { background:#f0f0f0; color:#9ab0c8; }
|
||||
.conn-dot { width:6px; height:6px; border-radius:50%; }
|
||||
.conn.on .conn-dot { background:#1a9a50; box-shadow:0 0 4px #1a9a50; }
|
||||
.conn.off .conn-dot { background:#c0ccd8; }
|
||||
|
||||
.tle-info { font-size:10px; font-weight:600; padding:3px 8px; border-radius:20px; }
|
||||
.tle-info.fresh { background:#e0f0ff; color:#1a5a9a; }
|
||||
|
||||
.doppler-toggle {
|
||||
display:flex; align-items:center; gap:4px;
|
||||
font-size:10px; font-weight:700; padding:3px 10px;
|
||||
border-radius:20px; border:none; cursor:pointer;
|
||||
letter-spacing:.04em; transition:all .2s;
|
||||
}
|
||||
.doppler-toggle.don { background:#e0f7ee; color:#1a7a40; }
|
||||
.doppler-toggle.doff { background:#f0f0f0; color:#9ab0c8; text-decoration:line-through; }
|
||||
.doppler-toggle.don::before {
|
||||
content:''; display:inline-block;
|
||||
width:6px; height:6px; border-radius:50%;
|
||||
background:#1a9a50; margin-right:4px;
|
||||
animation:pulse-dot 1.5s infinite;
|
||||
}
|
||||
.tle-info.stale { background:#fff0e0; color:#9a5a1a; }
|
||||
@keyframes pulse-dot {
|
||||
0%,100% { opacity:1; transform:scale(1); }
|
||||
50% { opacity:.4; transform:scale(0.7); }
|
||||
}
|
||||
</style>
|
||||
439
frontend/src/components/WorldMap.svelte
Normal file
439
frontend/src/components/WorldMap.svelte
Normal file
@@ -0,0 +1,439 @@
|
||||
<script>
|
||||
import { onMount, onDestroy } from 'svelte'
|
||||
import { satPositions, trackedSat, satList, watchlist, settings, passes } from '../stores/satstore.js'
|
||||
import { Go } from '../lib/wails.js'
|
||||
import { formatRange } from '../lib/utils.js'
|
||||
import 'leaflet/dist/leaflet.css'
|
||||
|
||||
let mapEl, map, L
|
||||
let markers = {}
|
||||
let footprintCircle = null
|
||||
let groundtrackLayer = null
|
||||
let popupData = null
|
||||
let currentTracked = ''
|
||||
let popupX = null
|
||||
let popupY = null
|
||||
let dragging = false
|
||||
let dragOffX = 0, dragOffY = 0
|
||||
|
||||
function startDrag(e) {
|
||||
dragging = true
|
||||
dragOffX = e.clientX - (popupX ?? 0)
|
||||
dragOffY = e.clientY - (popupY ?? 0)
|
||||
e.preventDefault()
|
||||
}
|
||||
function onDrag(e) {
|
||||
if (!dragging) return
|
||||
popupX = e.clientX - dragOffX
|
||||
popupY = e.clientY - dragOffY
|
||||
}
|
||||
function stopDrag() { dragging = false }
|
||||
|
||||
// Reactive tick — updates every second, drives countdown in template
|
||||
let now = Date.now()
|
||||
setInterval(() => { now = Date.now() }, 1000)
|
||||
|
||||
function fmtPassTime(isoStr) {
|
||||
if (!isoStr) return '—'
|
||||
return new Date(isoStr).toUTCString().slice(17, 25) + ' UTC'
|
||||
}
|
||||
|
||||
// Reactive pass info — recomputes whenever now or $passes changes
|
||||
$: passInfo = (() => {
|
||||
const ps = $passes || []
|
||||
for (const p of ps) {
|
||||
const aos = new Date(p.aos).getTime()
|
||||
const los = new Date(p.los).getTime()
|
||||
if (now >= aos && now <= los) return { aos: p.aos, los: p.los, active: true, maxEl: p.maxEl }
|
||||
if (aos > now) return { aos: p.aos, los: p.los, active: false, maxEl: p.maxEl }
|
||||
}
|
||||
return null
|
||||
})()
|
||||
|
||||
// Reactive countdown string — recomputes every second
|
||||
$: countdownStr = (() => {
|
||||
if (!passInfo || passInfo.active) return ''
|
||||
const diff = Math.floor((new Date(passInfo.aos).getTime() - now) / 1000)
|
||||
if (diff <= 0) return ''
|
||||
const m = Math.floor(diff / 60), s = diff % 60
|
||||
return `in ${m}m ${String(s).padStart(2,'0')}s`
|
||||
})()
|
||||
|
||||
$: countdownLOS = (() => {
|
||||
if (!passInfo?.active) return ''
|
||||
const diff = Math.floor((new Date(passInfo.los).getTime() - now) / 1000)
|
||||
if (diff <= 0) return ''
|
||||
const m = Math.floor(diff / 60), s = diff % 60
|
||||
return `${m}m ${String(s).padStart(2,'0')}s`
|
||||
})()
|
||||
let cleanups = []
|
||||
|
||||
// Watchlist panel
|
||||
let showWatchlistPanel = false
|
||||
let wlSearch = ''
|
||||
let wlSelected = []
|
||||
|
||||
onMount(async () => {
|
||||
L = (await import('leaflet')).default
|
||||
|
||||
// Map is ALWAYS visible (z-index approach in App.svelte)
|
||||
// So Leaflet can measure container correctly on first render
|
||||
map = L.map(mapEl, {
|
||||
center: [15, 0], zoom: 2,
|
||||
zoomControl: false, attributionControl: true,
|
||||
minZoom: 2, maxZoom: 8,
|
||||
maxBounds: [[-90, -200], [90, 200]],
|
||||
maxBoundsViscosity: 1.0,
|
||||
worldCopyJump: false,
|
||||
})
|
||||
|
||||
// NASA GIBS Blue Marble - satellite imagery like SatTrack
|
||||
L.tileLayer('https://map1.vis.earthdata.nasa.gov/wmts-webmerc/BlueMarble_NextGeneration/default/GoogleMapsCompatible_Level8/{z}/{y}/{x}.jpg', {
|
||||
attribution: 'Imagery © NASA EOSDIS GIBS',
|
||||
maxZoom: 8, noWrap: true, tileSize: 256,
|
||||
}).addTo(map)
|
||||
|
||||
// Fill width: zoom so 360° longitude exactly fills container width
|
||||
const fitWidth = () => {
|
||||
const el = mapEl
|
||||
if (!el || el.clientWidth === 0) return
|
||||
map.invalidateSize({ animate: false, pan: false })
|
||||
// At tile size 256: world_px = 256 * 2^z => z = log2(width / 256)
|
||||
const z = Math.log2(el.clientWidth / 256)
|
||||
map.setView([15, 0], z, { animate: false })
|
||||
map.setMinZoom(z - 0.1)
|
||||
}
|
||||
|
||||
requestAnimationFrame(() => {
|
||||
fitWidth()
|
||||
setTimeout(fitWidth, 200)
|
||||
setTimeout(fitWidth, 500)
|
||||
})
|
||||
|
||||
const ro = new ResizeObserver(() => {
|
||||
fitWidth()
|
||||
setTimeout(fitWidth, 200)
|
||||
})
|
||||
ro.observe(mapEl)
|
||||
cleanups.push(() => ro.disconnect())
|
||||
|
||||
// QTH marker
|
||||
const qthIcon = L.divIcon({
|
||||
html: `<svg width="14" height="14" viewBox="0 0 14 14" xmlns="http://www.w3.org/2000/svg">
|
||||
<line x1="7" y1="1" x2="7" y2="13" stroke="#cc2020" stroke-width="2.5" stroke-linecap="round"/>
|
||||
<line x1="1" y1="7" x2="13" y2="7" stroke="#cc2020" stroke-width="2.5" stroke-linecap="round"/>
|
||||
</svg>`,
|
||||
className: '', iconSize:[14,14], iconAnchor:[7,7]
|
||||
})
|
||||
let qthMarker = L.marker([$settings.qthLat || 48.7, $settings.qthLon || 2.55], {
|
||||
icon: qthIcon, zIndexOffset: 1000
|
||||
}).addTo(map)
|
||||
cleanups.push(settings.subscribe(s => {
|
||||
if (s.qthLat && s.qthLon) qthMarker.setLatLng([s.qthLat, s.qthLon])
|
||||
}))
|
||||
|
||||
cleanups.push(satPositions.subscribe(p => updateMarkers(p)))
|
||||
|
||||
cleanups.push(trackedSat.subscribe(async name => {
|
||||
Object.values(markers).forEach(m => m.remove())
|
||||
markers = {}
|
||||
footprintCircle?.remove(); footprintCircle = null
|
||||
if (footprintSvgLayer) { map.removeLayer(footprintSvgLayer); footprintSvgLayer = null }
|
||||
groundtrackLayer?.remove(); groundtrackLayer = null
|
||||
popupData = null
|
||||
currentTracked = name
|
||||
if (name) {
|
||||
const track = await Go.GetGroundtrack(name, 100)
|
||||
if (track?.length > 1) drawGroundtrack(track)
|
||||
}
|
||||
}))
|
||||
|
||||
cleanups.push(watchlist.subscribe(() => {
|
||||
Object.values(markers).forEach(m => m.remove())
|
||||
markers = {}
|
||||
footprintCircle?.remove(); footprintCircle = null
|
||||
}))
|
||||
|
||||
map.on('click', () => { popupData = null })
|
||||
})
|
||||
|
||||
onDestroy(() => { cleanups.forEach(fn => fn?.()); map?.remove() })
|
||||
|
||||
const SAT_SVG = `<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 32 32" width="28" height="28">
|
||||
<rect x="0" y="12" width="9" height="8" rx="1" fill="#5ba3e0" stroke="white" stroke-width="1"/>
|
||||
<line x1="3" y1="12" x2="3" y2="20" stroke="white" stroke-width="0.5" opacity="0.6"/>
|
||||
<line x1="6" y1="12" x2="6" y2="20" stroke="white" stroke-width="0.5" opacity="0.6"/>
|
||||
<rect x="23" y="12" width="9" height="8" rx="1" fill="#5ba3e0" stroke="white" stroke-width="1"/>
|
||||
<line x1="26" y1="12" x2="26" y2="20" stroke="white" stroke-width="0.5" opacity="0.6"/>
|
||||
<line x1="29" y1="12" x2="29" y2="20" stroke="white" stroke-width="0.5" opacity="0.6"/>
|
||||
<rect x="10" y="10" width="12" height="12" rx="2" fill="#1a5a9a" stroke="white" stroke-width="1.5"/>
|
||||
<line x1="16" y1="10" x2="16" y2="5" stroke="white" stroke-width="1.2"/>
|
||||
<circle cx="16" cy="4" r="1.5" fill="white"/>
|
||||
<circle cx="16" cy="16" r="2" fill="white" opacity="0.9"/>
|
||||
<circle cx="16" cy="16" r="14" fill="none" stroke="#40c8f0" stroke-width="1" opacity="0.5" stroke-dasharray="3,3"/>
|
||||
</svg>`
|
||||
|
||||
function makeIcon(name, isTracked, el) {
|
||||
const above = el >= 0
|
||||
const color = isTracked ? '#1a5a9a' : above ? '#2563eb' : '#9ca3af'
|
||||
const size = isTracked ? 14 : above ? 10 : 6
|
||||
const border = `2px solid white`
|
||||
const shadow = isTracked ? `box-shadow:0 0 0 2px rgba(26,90,154,0.4),0 2px 4px rgba(0,0,0,0.2)`
|
||||
: above ? `box-shadow:0 0 0 1px rgba(37,99,235,0.3),0 1px 3px rgba(0,0,0,0.2)`
|
||||
: `box-shadow:0 1px 2px rgba(0,0,0,0.15)`
|
||||
const label = isTracked
|
||||
? `<span class="slabel">${name.replace(' (ZARYA)','')}</span>` : ''
|
||||
if (isTracked) {
|
||||
return L.divIcon({
|
||||
html: `<div class="sw sw-sat">${SAT_SVG}${label}</div>`,
|
||||
className:'', iconSize:[0,0], iconAnchor:[14,14],
|
||||
})
|
||||
}
|
||||
return L.divIcon({
|
||||
html: `<div class="sw"><div class="sd" style="width:${size}px;height:${size}px;background:${color};border:${border};border-radius:50%;${shadow}"></div>${label}</div>`,
|
||||
className:'', iconSize:[0,0], iconAnchor:[0,0],
|
||||
})
|
||||
}
|
||||
|
||||
function updateMarkers(positions) {
|
||||
const wl = $watchlist
|
||||
const filtered = wl.length > 0 ? positions.filter(p => wl.some(w => p.name.toUpperCase() === w.toUpperCase())) : positions
|
||||
const seen = new Set()
|
||||
filtered.forEach(pos => {
|
||||
seen.add(pos.name)
|
||||
const isTracked = pos.name === currentTracked
|
||||
if (markers[pos.name]) {
|
||||
markers[pos.name].setLatLng([pos.lat, pos.lon])
|
||||
markers[pos.name].setIcon(makeIcon(pos.name, isTracked, pos.el))
|
||||
markers[pos.name]._pos = pos
|
||||
} else {
|
||||
const m = L.marker([pos.lat, pos.lon], { icon: makeIcon(pos.name, isTracked, pos.el), zIndexOffset: isTracked ? 999 : 0 }).addTo(map)
|
||||
m._pos = pos
|
||||
m.on('click', e => { L.DomEvent.stopPropagation(e); popupData = { ...pos }; trackedSat.set(pos.name) })
|
||||
markers[pos.name] = m
|
||||
}
|
||||
if (popupData?.name === pos.name) popupData = { ...pos }
|
||||
if (isTracked) {
|
||||
footprintCircle?.remove(); footprintCircle = null
|
||||
drawFootprintSVG(pos.lat, pos.lon, pos.footprint)
|
||||
}
|
||||
})
|
||||
Object.keys(markers).forEach(n => { if (!seen.has(n)) { markers[n].remove(); delete markers[n] } })
|
||||
}
|
||||
|
||||
// Footprint — split polylines at antimeridian and polar boundaries
|
||||
let footprintSvgLayer = null
|
||||
|
||||
function geoCircleRaw(lat, lon, radiusKm, n = 360) {
|
||||
const R = 6371.0, d = radiusKm / R
|
||||
const lat0 = lat * Math.PI / 180, lon0 = lon * Math.PI / 180
|
||||
const pts = []
|
||||
for (let i = 0; i < n; i++) {
|
||||
const b = (2 * Math.PI * i) / n
|
||||
const sinLat = Math.sin(lat0)*Math.cos(d) + Math.cos(lat0)*Math.sin(d)*Math.cos(b)
|
||||
const latR = Math.asin(Math.max(-1, Math.min(1, sinLat)))
|
||||
const lonR = lon0 + Math.atan2(Math.sin(b)*Math.sin(d)*Math.cos(lat0), Math.cos(d)-Math.sin(lat0)*sinLat)
|
||||
const pLat = latR * 180 / Math.PI
|
||||
let pLon = lonR * 180 / Math.PI
|
||||
while (pLon > 180) pLon -= 360
|
||||
while (pLon < -180) pLon += 360
|
||||
pts.push([pLat, pLon])
|
||||
}
|
||||
return pts
|
||||
}
|
||||
|
||||
function drawFootprintSVG(lat, lon, radiusKm) {
|
||||
if (footprintSvgLayer) { map.removeLayer(footprintSvgLayer); footprintSvgLayer = null }
|
||||
if (!radiusKm || radiusKm <= 0) return
|
||||
|
||||
const pts = geoCircleRaw(lat, lon, radiusKm)
|
||||
const layers = []
|
||||
|
||||
// Split into segments on antimeridian jumps or polar clipping
|
||||
const segments = []
|
||||
let seg = []
|
||||
for (let i = 0; i < pts.length; i++) {
|
||||
const [pLat, pLon] = pts[i]
|
||||
// Skip points beyond Mercator limits
|
||||
if (Math.abs(pLat) > 85) {
|
||||
if (seg.length > 1) segments.push(seg)
|
||||
seg = []
|
||||
continue
|
||||
}
|
||||
if (seg.length > 0) {
|
||||
const prev = seg[seg.length - 1]
|
||||
// Antimeridian jump: lon difference > 180°
|
||||
if (Math.abs(pLon - prev[1]) > 180) {
|
||||
if (seg.length > 1) segments.push(seg)
|
||||
seg = []
|
||||
}
|
||||
}
|
||||
seg.push([pLat, pLon])
|
||||
}
|
||||
if (seg.length > 1) segments.push(seg)
|
||||
|
||||
// Draw each segment as a polyline
|
||||
const polylines = segments.map(s =>
|
||||
L.polyline(s, {
|
||||
color: '#1a5a9a', weight: 1.5,
|
||||
dashArray: '7,6', opacity: 0.75,
|
||||
smoothFactor: 0,
|
||||
})
|
||||
)
|
||||
|
||||
// Fill only if single segment (no antimeridian crossing)
|
||||
if (segments.length === 1 && segments[0].length > 2) {
|
||||
layers.push(L.polygon(segments[0], {
|
||||
color: 'transparent', fillColor: '#1a5a9a',
|
||||
fillOpacity: 0.07, weight: 0,
|
||||
}))
|
||||
}
|
||||
|
||||
footprintSvgLayer = L.layerGroup([...layers, ...polylines]).addTo(map)
|
||||
}
|
||||
|
||||
function drawGroundtrack(track) {
|
||||
groundtrackLayer?.remove()
|
||||
const segs = []; let seg = []
|
||||
track.forEach((pt, i) => {
|
||||
if (i > 0 && Math.abs(pt.lon - track[i-1].lon) > 180) { if (seg.length > 1) segs.push([...seg]); seg = [] }
|
||||
seg.push([pt.lat, pt.lon])
|
||||
})
|
||||
if (seg.length > 1) segs.push(seg)
|
||||
groundtrackLayer = L.layerGroup(segs.map(pts => L.polyline(pts, { color:'#1a5a9a', weight:2, opacity:.75, dashArray:'8,5' }))).addTo(map)
|
||||
}
|
||||
|
||||
function openWatchlistPanel() { wlSelected = [...$watchlist]; showWatchlistPanel = true }
|
||||
function addToSelected(n) { if (!wlSelected.includes(n)) wlSelected = [...wlSelected, n] }
|
||||
function removeFromSelected(n) { wlSelected = wlSelected.filter(x => x !== n) }
|
||||
async function applyWatchlist() { watchlist.set([...wlSelected]); await Go.SetWatchlist(wlSelected); showWatchlistPanel = false }
|
||||
$: filteredAvail = $satList.filter(n => !wlSelected.includes(n) && (!wlSearch || n.toLowerCase().includes(wlSearch.toLowerCase()))).sort()
|
||||
</script>
|
||||
|
||||
<div class="mc" on:mousemove={onDrag} on:mouseup={stopDrag} on:mouseleave={stopDrag}>
|
||||
<div bind:this={mapEl} class="map"></div>
|
||||
|
||||
{#if popupData}
|
||||
<div class="popup"
|
||||
style={popupX !== null ? `left:${popupX}px;top:${popupY}px;transform:none` : ''}
|
||||
on:mousedown={startDrag}>
|
||||
<div class="ph"><span class="pname">{popupData.name}</span><span class="drag-hint">⠿</span><button class="px" on:click={() => popupData=null}>×</button></div>
|
||||
{#if passInfo}
|
||||
<div class="pass-row {passInfo.active ? 'pass-live' : 'pass-next'}">
|
||||
{#if passInfo.active}
|
||||
<span class="pass-dot"></span>
|
||||
<span class="pass-label">PASS IN PROGRESS</span>
|
||||
<span class="pass-maxel">Max {passInfo.maxEl?.toFixed(0)}°</span>
|
||||
<span class="pass-time">LOS {fmtPassTime(passInfo.los)} · {countdownLOS}</span>
|
||||
{:else}
|
||||
<span class="pass-label">NEXT PASS</span>
|
||||
<span class="pass-maxel">Max {passInfo.maxEl?.toFixed(0)}°</span>
|
||||
<span class="pass-time">AOS {fmtPassTime(passInfo.aos)} · {countdownStr}</span>
|
||||
{/if}
|
||||
</div>
|
||||
{/if}
|
||||
<div class="pg">
|
||||
<span class="pk">Azimuth</span> <span class="pv">{popupData.az?.toFixed(1)}°</span>
|
||||
<span class="pk">Elevation</span> <span class="pv {popupData.el>=0?'up':''}">{popupData.el?.toFixed(1)}°</span>
|
||||
<span class="pk">Range</span> <span class="pv">{formatRange(popupData.range)}</span>
|
||||
<span class="pk">Altitude</span> <span class="pv">{popupData.alt?.toFixed(0)} km</span>
|
||||
<span class="pk">Radial</span> <span class="pv {popupData.rangeRate<0?'app':'rec'}">{popupData.rangeRate<0?'▼ Approach':'▲ Recede'} {popupData.rangeRate ? Math.abs(Math.round(popupData.rangeRate*3600)).toLocaleString('en-US') : '—'} km/h</span>
|
||||
<span class="pk">Footprint (d)</span><span class="pv">{formatRange(popupData.footprint * 2)}</span>
|
||||
</div>
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<button class="wl-btn" on:click={openWatchlistPanel}>🛰 Satellites ({$watchlist.length})</button>
|
||||
|
||||
{#if showWatchlistPanel}
|
||||
<div class="wl-panel">
|
||||
<div class="wl-hdr"><span>Sélection des satellites</span><button class="btn-apply" on:click={applyWatchlist}>✓ Appliquer</button><button class="px" on:click={() => showWatchlistPanel=false}>×</button></div>
|
||||
<div class="wl-body">
|
||||
<div class="wl-col">
|
||||
<div class="wl-col-hdr">Disponibles <input bind:value={wlSearch} placeholder="Filtrer…" class="wl-search"/></div>
|
||||
<div class="wl-list">{#each filteredAvail.slice(0,200) as name}<button class="wl-item" on:click={() => addToSelected(name)}><span class="wl-add">+</span>{name}</button>{/each}</div>
|
||||
</div>
|
||||
<div class="wl-sep">→</div>
|
||||
<div class="wl-col wl-col-r">
|
||||
<div class="wl-col-hdr">Sélectionnés ({wlSelected.length}) <button class="btn-clear" on:click={() => wlSelected=[]}>Effacer</button></div>
|
||||
<div class="wl-list">
|
||||
{#each [...wlSelected].sort() as name}<button class="wl-item wl-item-sel" on:click={() => removeFromSelected(name)}><span class="wl-rem">×</span>{name}</button>{/each}
|
||||
{#if !wlSelected.length}<div class="wl-empty">Aucun satellite</div>{/if}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
<style>
|
||||
.mc { position:relative; width:100%; height:100%; background:#a8c8e8; overflow:hidden; }
|
||||
.map { width:100%; height:100%; position:absolute; top:0; left:0; right:0; bottom:0; }
|
||||
|
||||
:global(.sw) { display:flex; align-items:center; gap:5px; pointer-events:none; }
|
||||
:global(.sd) { border-radius:50%; cursor:pointer; pointer-events:all; flex-shrink:0; transition:transform .15s; }
|
||||
:global(.sw-sat) { display:flex; flex-direction:column; align-items:center; gap:3px; filter:drop-shadow(0 0 6px rgba(64,200,240,0.7)); animation:sat-pulse 2s ease-in-out infinite; }
|
||||
:global(.sw-sat svg) { cursor:pointer; }
|
||||
@keyframes sat-pulse { 0%,100%{filter:drop-shadow(0 0 5px rgba(64,200,240,0.6))} 50%{filter:drop-shadow(0 0 10px rgba(64,200,240,1))} }
|
||||
:global(.sd:hover) { transform:scale(2); }
|
||||
:global(.slabel) {
|
||||
font-family:'Inter',sans-serif; font-size:11px; font-weight:700;
|
||||
color:white; background:#1a5a9a;
|
||||
padding:2px 7px; border-radius:4px;
|
||||
white-space:nowrap; pointer-events:none;
|
||||
box-shadow:0 1px 3px rgba(0,0,0,0.3);
|
||||
margin-left:4px;
|
||||
}
|
||||
|
||||
:global(.leaflet-control-zoom a) { background:#1e2e42 !important; color:#40c8f0 !important; border-color:#3a5a7a !important; }
|
||||
:global(.leaflet-control-attribution) { font-size:9px !important; background:rgba(255,255,255,0.7) !important; color:#666 !important; }
|
||||
|
||||
.popup { position:absolute; top:50px; left:50%; transform:translateX(-50%); z-index:800; cursor:default; user-select:none; background:white; border:1px solid #c0d4e8; border-radius:10px; min-width:280px; box-shadow:0 8px 32px rgba(0,0,0,.6); font-family:'Inter',sans-serif; }
|
||||
.ph { display:flex; justify-content:space-between; align-items:center; padding:10px 14px; cursor:grab; background:#f0f6ff; border-radius:10px 10px 0 0; border-bottom:1px solid #d0e0f0; }
|
||||
.pname { font-size:14px; font-weight:bold; color:#1a5a9a; flex:1; }
|
||||
.drag-hint { color:#c0ccd8; font-size:14px; cursor:grab; margin:0 6px; }
|
||||
.px { background:none; border:none; color:#9ab0c8; font-size:18px; cursor:pointer; padding:0; }
|
||||
.px:hover { color:#1a2a3a; }
|
||||
.pg { display:grid; grid-template-columns:1fr 1fr; gap:6px 12px; padding:12px 14px; font-family:"Inter",sans-serif; }
|
||||
.pk { font-size:10px; color:#9ab0c8; text-transform:uppercase; letter-spacing:.06em; align-self:center; font-weight:600; }
|
||||
.pv { font-size:13px; color:#1a2a3a; font-weight:700; }
|
||||
.pv.up { color:#1a9a50; } .pv.app { color:#1a9a50; } .pv.rec { color:#1a5a9a; }
|
||||
|
||||
.pass-row {
|
||||
display:flex; align-items:center; gap:8px; flex-wrap:wrap;
|
||||
padding:7px 14px; font-size:11px; font-weight:600;
|
||||
border-bottom:1px solid #e0eaf4;
|
||||
}
|
||||
.pass-live { background:#f0f9f4; color:#1a7a40; }
|
||||
.pass-next { background:#f0f6ff; color:#1a5a9a; }
|
||||
.pass-dot { width:7px; height:7px; border-radius:50%; background:#1a9a50; box-shadow:0 0 5px #1a9a50; animation:blink 1.2s infinite; flex-shrink:0; }
|
||||
.pass-label { font-weight:700; letter-spacing:.04em; font-size:10px; text-transform:uppercase; }
|
||||
.pass-maxel { background:rgba(0,0,0,0.06); padding:1px 6px; border-radius:10px; font-size:10px; }
|
||||
.pass-time { color:inherit; opacity:.8; font-weight:500; margin-left:auto; font-size:10px; }
|
||||
@keyframes blink { 0%,100%{opacity:1} 50%{opacity:.3} }
|
||||
|
||||
.wl-btn { position:absolute; top:10px; left:10px; z-index:700; background:rgba(26,45,68,.85); border:1px solid #4a7aaa; color:#40c8f0; font-family:'Inter',sans-serif; font-size:12px; padding:7px 14px; border-radius:6px; cursor:pointer; backdrop-filter:blur(4px); }
|
||||
.wl-btn:hover { background:rgba(36,62,90,.9); }
|
||||
|
||||
.wl-panel { position:absolute; top:46px; left:10px; z-index:700; background:#1a2d44; border:1px solid #4a7aaa; border-radius:10px; width:580px; height:420px; display:flex; flex-direction:column; box-shadow:0 8px 32px rgba(0,0,0,.7); font-family:'Inter',sans-serif; }
|
||||
.wl-hdr { display:flex; align-items:center; gap:8px; padding:10px 14px; border-bottom:1px solid #2a4a6a; background:#162336; border-radius:10px 10px 0 0; font-size:11px; color:#7aaac8; flex-shrink:0; }
|
||||
.wl-hdr span { flex:1; }
|
||||
.btn-apply { background:#1a5a9a; border:none; color:#c8e8ff; font-family:'Inter',sans-serif; font-size:11px; padding:5px 12px; border-radius:4px; cursor:pointer; font-weight:bold; }
|
||||
.btn-clear { background:none; border:1px solid #3a5a7a; color:#5a8ab0; font-family:'Inter',sans-serif; font-size:10px; padding:3px 8px; border-radius:3px; cursor:pointer; }
|
||||
.wl-body { display:flex; flex:1; overflow:hidden; }
|
||||
.wl-col { display:flex; flex-direction:column; flex:1; overflow:hidden; }
|
||||
.wl-col-r { border-left:1px solid #2a4a6a; }
|
||||
.wl-col-hdr { display:flex; align-items:center; gap:8px; padding:7px 10px; background:#162336; border-bottom:1px solid #2a4a6a; font-size:10px; color:#5a8ab0; flex-shrink:0; }
|
||||
.wl-search { flex:1; background:#0e1824; border:1px solid #2a4a6a; color:#c0daf0; padding:3px 8px; font-family:'Inter',sans-serif; font-size:10px; border-radius:4px; outline:none; }
|
||||
.wl-sep { display:flex; align-items:center; justify-content:center; width:30px; flex-shrink:0; color:#3a6a8a; font-size:12px; }
|
||||
.wl-list { overflow-y:auto; flex:1; }
|
||||
.wl-list::-webkit-scrollbar { width:3px; }
|
||||
.wl-list::-webkit-scrollbar-thumb { background:#3a5a7a; }
|
||||
.wl-item { display:flex; align-items:center; gap:6px; width:100%; background:none; border:none; border-bottom:1px solid #1a2a3a; color:#7aaac8; font-family:'Inter',sans-serif; font-size:10px; padding:6px 10px; text-align:left; cursor:pointer; white-space:nowrap; overflow:hidden; text-overflow:ellipsis; }
|
||||
.wl-item:hover { background:#243444; color:#c0daf0; }
|
||||
.wl-item-sel { color:#40c8f0; }
|
||||
.wl-item-sel:hover { color:#ff7070; background:#1a3040; }
|
||||
.wl-add { color:#40e888; font-size:12px; font-weight:bold; flex-shrink:0; }
|
||||
.wl-rem { color:#f07060; font-size:12px; font-weight:bold; flex-shrink:0; }
|
||||
.wl-empty { padding:20px; text-align:center; color:#3a6a8a; font-size:10px; }
|
||||
</style>
|
||||
56
frontend/src/lib/maidenhead.js
Normal file
56
frontend/src/lib/maidenhead.js
Normal file
@@ -0,0 +1,56 @@
|
||||
/**
|
||||
* Convert a Maidenhead grid locator to lat/lon (center of the square).
|
||||
* Supports 4, 6, or 8 character locators (e.g. JN03, JN03ef, JN03ef12).
|
||||
* Returns { lat, lon } in decimal degrees, or null if invalid.
|
||||
*/
|
||||
export function maidenheadToLatLon(locator) {
|
||||
const loc = locator.trim().toUpperCase()
|
||||
if (!/^[A-R]{2}[0-9]{2}([A-X]{2}([0-9]{2})?)?$/.test(loc)) return null
|
||||
|
||||
// Field (A-R) → 0-17, each = 20°
|
||||
const lonBase = (loc.charCodeAt(0) - 65) * 20 - 180
|
||||
const latBase = (loc.charCodeAt(1) - 65) * 10 - 90
|
||||
|
||||
// Square (0-9) → each = 2° lon, 1° lat
|
||||
let lon = lonBase + parseInt(loc[2]) * 2
|
||||
let lat = latBase + parseInt(loc[3]) * 1
|
||||
|
||||
// Subsquare (a-x) → each = 5' lon, 2.5' lat
|
||||
if (loc.length >= 6) {
|
||||
lon += (loc.charCodeAt(4) - 65) * (2 / 24)
|
||||
lat += (loc.charCodeAt(5) - 65) * (1 / 24)
|
||||
// Center of subsquare
|
||||
lon += 1 / 24
|
||||
lat += 0.5 / 24
|
||||
} else {
|
||||
// Center of square
|
||||
lon += 1
|
||||
lat += 0.5
|
||||
}
|
||||
|
||||
// Extended subsquare (0-9)
|
||||
if (loc.length >= 8) {
|
||||
lon += parseInt(loc[6]) * (2 / 240)
|
||||
lat += parseInt(loc[7]) * (1 / 240)
|
||||
lon += 1 / 240
|
||||
lat += 0.5 / 240
|
||||
}
|
||||
|
||||
return { lat: parseFloat(lat.toFixed(6)), lon: parseFloat(lon.toFixed(6)) }
|
||||
}
|
||||
|
||||
/**
|
||||
* Convert lat/lon to a 6-character Maidenhead locator.
|
||||
*/
|
||||
export function latLonToMaidenhead(lat, lon) {
|
||||
lon += 180
|
||||
lat += 90
|
||||
const A = 'ABCDEFGHIJKLMNOPQRSTUVWX'
|
||||
const f = String.fromCharCode(65 + Math.floor(lon / 20))
|
||||
const s = String.fromCharCode(65 + Math.floor(lat / 10))
|
||||
const n1 = Math.floor((lon % 20) / 2)
|
||||
const n2 = Math.floor(lat % 10)
|
||||
const ss1 = A[Math.floor((lon % 2) / (2 / 24))]
|
||||
const ss2 = A[Math.floor((lat % 1) / (1 / 24))]
|
||||
return `${f}${s}${n1}${n2}${ss1.toLowerCase()}${ss2.toLowerCase()}`
|
||||
}
|
||||
422
frontend/src/lib/satdb.js
Normal file
422
frontend/src/lib/satdb.js
Normal file
@@ -0,0 +1,422 @@
|
||||
/**
|
||||
* Base de données des fréquences satellites amateur
|
||||
* Source: AMSAT Live Satellite Frequencies, Feb 2026 + updated DB Mar 2026
|
||||
*/
|
||||
|
||||
export const SAT_TYPE = {
|
||||
FM: 'FM', LINEAR: 'Linear', APRS: 'APRS', CW: 'CW', DATA: 'Data',
|
||||
}
|
||||
|
||||
export const TYPE_STYLE = {
|
||||
FM: { bg: '#e0f0ff', color: '#1a5a9a', border: '#b0d0f0' },
|
||||
Linear: { bg: '#e8f8ee', color: '#1a7a40', border: '#a0d8b0' },
|
||||
APRS: { bg: '#fff8e0', color: '#8a6010', border: '#e0c870' },
|
||||
CW: { bg: '#f5f0ff', color: '#5a30a0', border: '#c0a0e0' },
|
||||
Data: { bg: '#f0f0f0', color: '#5a6a7a', border: '#c0cad0' },
|
||||
}
|
||||
|
||||
const DB = [
|
||||
// ── FM Voice ───────────────────────────────────────────────────────────────
|
||||
{
|
||||
keys: ['AO-91', 'FOX-1B', 'RADFXSAT', 'AO-91 (RADFXSAT/FOX-1B)'],
|
||||
displayName: 'AO-91 (RadFxSat/Fox-1B)',
|
||||
type: SAT_TYPE.FM,
|
||||
freqs: [{ downHz: 145960000, upHz: 435250000, mode: 'FM', notes: 'CTCSS 67 Hz — Do not use during eclipse (battery status)' }]
|
||||
},
|
||||
{
|
||||
keys: ['AO-92', 'FOX-1D'],
|
||||
displayName: 'AO-92 (Fox-1D)',
|
||||
type: SAT_TYPE.FM,
|
||||
freqs: [{ downHz: 145880000, upHz: 435350000, mode: 'FM', notes: 'CTCSS 67 Hz. Re-entered.' }]
|
||||
},
|
||||
{
|
||||
keys: ['AO-123', 'ASRTU-1'],
|
||||
displayName: 'AO-123 (ASRTU-1)',
|
||||
type: SAT_TYPE.FM,
|
||||
freqs: [{ downHz: 435400000, upHz: 145850000, mode: 'FM', notes: 'CTCSS 67 Hz — FM/9600bps BPSK QPSK SSDV. Active everytime.' }]
|
||||
},
|
||||
{
|
||||
keys: ['AO-27', 'EYESAT-A'],
|
||||
displayName: 'AO-27 (EYESAT-A)',
|
||||
type: SAT_TYPE.FM,
|
||||
freqs: [{ downHz: 436795000, upHz: 145850000, mode: 'FM', notes: 'FM / 1200bps AFSK. Inactive.' }]
|
||||
},
|
||||
{
|
||||
keys: ['CAS-3H', 'LILACSAT-2'],
|
||||
displayName: 'CAS-3H (LilacSat-2)',
|
||||
type: SAT_TYPE.FM,
|
||||
freqs: [
|
||||
{ downHz: 437200000, upHz: 144350000, mode: 'FM', notes: 'No CTCSS - FM transponder has no set schedule' },
|
||||
{ downHz: 437225000, upHz: 145875000, mode: 'APRS', notes: 'APRS / FSK downlink' },
|
||||
]
|
||||
},
|
||||
{
|
||||
keys: ['IO-86', 'LAPAN-A2'],
|
||||
displayName: 'IO-86 (LAPAN-A2)',
|
||||
type: SAT_TYPE.FM,
|
||||
freqs: [{ downHz: 435880000, upHz: 145880000, mode: 'FM', notes: 'CTCSS 88.5 Hz — Low inclination orbit ±30°' }]
|
||||
},
|
||||
{
|
||||
keys: ['ISS (ZARYA)', 'ISS', 'ZARYA'],
|
||||
displayName: 'ISS',
|
||||
type: SAT_TYPE.FM,
|
||||
freqs: [
|
||||
{ downHz: 437800000, upHz: 145990000, mode: 'FM', notes: 'FM Voice, CTCSS 67.0 Hz. Active sometimes.' },
|
||||
{ downHz: 144825000, upHz: 0, mode: 'APRS', notes: 'APRS Europe 144.825 MHz / 1200bps AFSK' },
|
||||
]
|
||||
},
|
||||
{
|
||||
keys: ['PO-101', 'DIWATA-2B'],
|
||||
displayName: 'PO-101 (Diwata-2)',
|
||||
type: SAT_TYPE.FM,
|
||||
freqs: [{ downHz: 145900000, upHz: 437500000, mode: 'FM', notes: 'CTCSS 141.3 Hz. Active sometimes.' }]
|
||||
},
|
||||
{
|
||||
keys: ['RS95S', 'QMR-KWT-2'],
|
||||
displayName: 'RS95S (QMR-KWT-2)',
|
||||
type: SAT_TYPE.FM,
|
||||
freqs: [{ downHz: 436950000, upHz: 145920000, mode: 'FM', notes: 'CTCSS 67 Hz' }]
|
||||
},
|
||||
{
|
||||
keys: ['SO-50', 'SAUDISAT-1C'],
|
||||
displayName: 'SO-50 (SaudiSat-1C)',
|
||||
type: SAT_TYPE.FM,
|
||||
freqs: [{ downHz: 436795000, upHz: 145850000, mode: 'FM', notes: 'Arm with 74.4 Hz tone (2s carrier), then CTCSS 67 Hz. Active everytime.' }]
|
||||
},
|
||||
{
|
||||
keys: ['SO-125', 'HADES-ICM'],
|
||||
displayName: 'SO-125 (HADES-ICM)',
|
||||
type: SAT_TYPE.FM,
|
||||
freqs: [{ downHz: 436666000, upHz: 145875000, mode: 'FM', notes: 'FM/200–2400bps USB. Beacon 436.666 MHz. Active everytime.' }]
|
||||
},
|
||||
{
|
||||
keys: ['SO-124', 'HADES-R'],
|
||||
displayName: 'SO-124 (HADES-R)',
|
||||
type: SAT_TYPE.FM,
|
||||
freqs: [{ downHz: 436888000, upHz: 145925000, mode: 'FM', notes: 'FM/200–2400bps USB. Beacon 436.888 MHz. Active sometimes.' }]
|
||||
},
|
||||
{
|
||||
keys: ['SO-121', 'HADES-D'],
|
||||
displayName: 'SO-121 (HADES-D)',
|
||||
type: SAT_TYPE.FM,
|
||||
freqs: [{ downHz: 436666000, upHz: 145875000, mode: 'FM', notes: 'FM/USB 50–9600bps. Beacon 436.666 MHz. Re-entered.' }]
|
||||
},
|
||||
{
|
||||
keys: ['LO-87'],
|
||||
displayName: 'LO-87',
|
||||
type: SAT_TYPE.FM,
|
||||
freqs: [{ downHz: 436900000, upHz: 145990000, mode: 'FM', notes: 'FM Voice' }]
|
||||
},
|
||||
{
|
||||
keys: ['EO-88', 'NAYIF-1'],
|
||||
displayName: 'EO-88 (Nayif-1)',
|
||||
type: SAT_TYPE.FM,
|
||||
freqs: [{ downHz: 145960000, upHz: 435015000, mode: 'FM', notes: 'CTCSS 67 Hz. Re-entered.' }]
|
||||
},
|
||||
{
|
||||
keys: ['AO-85', 'FOX-1A'],
|
||||
displayName: 'AO-85 (Fox-1A)',
|
||||
type: SAT_TYPE.FM,
|
||||
freqs: [{ downHz: 145980000, upHz: 435182000, mode: 'FM', notes: 'FM CTCSS 67.0 Hz / 200bps DUV. Breakdown.' }]
|
||||
},
|
||||
{
|
||||
keys: ['AO-95', 'FOX-1CLIFF'],
|
||||
displayName: 'AO-95 (Fox-1Cliff)',
|
||||
type: SAT_TYPE.FM,
|
||||
freqs: [{ downHz: 145920000, upHz: 435300000, mode: 'FM', notes: 'FM CTCSS 67.0 Hz / DUV only. Inactive.' }]
|
||||
},
|
||||
{
|
||||
keys: ['UVSQ-SAT', 'UVSQ'],
|
||||
displayName: 'UVSQ-Sat',
|
||||
type: SAT_TYPE.FM,
|
||||
freqs: [{ downHz: 437020000, upHz: 145905000, mode: 'FM', notes: 'FM / 1200–9600bps BPSK. Re-entered.' }]
|
||||
},
|
||||
{
|
||||
keys: ['TEVEL-1'],
|
||||
displayName: 'TEVEL-1',
|
||||
type: SAT_TYPE.FM,
|
||||
freqs: [{ downHz: 436400000, upHz: 145970000, mode: 'FM', notes: 'FM / 9600bps BPSK. Re-entered.' }]
|
||||
},
|
||||
{
|
||||
keys: ['TEVEL-2'],
|
||||
displayName: 'TEVEL-2',
|
||||
type: SAT_TYPE.FM,
|
||||
freqs: [{ downHz: 436400000, upHz: 145970000, mode: 'FM', notes: 'FM / 9600bps BPSK. Re-entered.' }]
|
||||
},
|
||||
{
|
||||
keys: ['TEVEL-3'],
|
||||
displayName: 'TEVEL-3',
|
||||
type: SAT_TYPE.FM,
|
||||
freqs: [{ downHz: 436400000, upHz: 145970000, mode: 'FM', notes: 'FM / 9600bps BPSK. Re-entered.' }]
|
||||
},
|
||||
{
|
||||
keys: ['TEVEL-4'],
|
||||
displayName: 'TEVEL-4',
|
||||
type: SAT_TYPE.FM,
|
||||
freqs: [{ downHz: 436400000, upHz: 145970000, mode: 'FM', notes: 'FM / 9600bps BPSK. Re-entered.' }]
|
||||
},
|
||||
{
|
||||
keys: ['TEVEL-5'],
|
||||
displayName: 'TEVEL-5',
|
||||
type: SAT_TYPE.FM,
|
||||
freqs: [{ downHz: 436400000, upHz: 145970000, mode: 'FM', notes: 'FM / 9600bps BPSK. Re-entered.' }]
|
||||
},
|
||||
{
|
||||
keys: ['TEVEL-6'],
|
||||
displayName: 'TEVEL-6',
|
||||
type: SAT_TYPE.FM,
|
||||
freqs: [{ downHz: 436400000, upHz: 145970000, mode: 'FM', notes: 'FM / 9600bps BPSK. Re-entered.' }]
|
||||
},
|
||||
{
|
||||
keys: ['TEVEL-7'],
|
||||
displayName: 'TEVEL-7',
|
||||
type: SAT_TYPE.FM,
|
||||
freqs: [{ downHz: 436400000, upHz: 145970000, mode: 'FM', notes: 'FM / 9600bps BPSK. Re-entered.' }]
|
||||
},
|
||||
{
|
||||
keys: ['TEVEL-8'],
|
||||
displayName: 'TEVEL-8',
|
||||
type: SAT_TYPE.FM,
|
||||
freqs: [{ downHz: 436400000, upHz: 145970000, mode: 'FM', notes: 'FM / 9600bps BPSK. Re-entered.' }]
|
||||
},
|
||||
{
|
||||
keys: ['TEVEL2-1'],
|
||||
displayName: 'TEVEL2-1',
|
||||
type: SAT_TYPE.FM,
|
||||
freqs: [{ downHz: 436400000, upHz: 145970000, mode: 'FM', notes: 'FM / 9600bps BPSK. Active sometimes.' }]
|
||||
},
|
||||
{
|
||||
keys: ['TEVEL2-2'],
|
||||
displayName: 'TEVEL2-2',
|
||||
type: SAT_TYPE.FM,
|
||||
freqs: [{ downHz: 436400000, upHz: 145970000, mode: 'FM', notes: 'FM / 9600bps BPSK. Active sometimes.' }]
|
||||
},
|
||||
{
|
||||
keys: ['TEVEL2-3'],
|
||||
displayName: 'TEVEL2-3',
|
||||
type: SAT_TYPE.FM,
|
||||
freqs: [{ downHz: 436400000, upHz: 145970000, mode: 'FM', notes: 'FM / 9600bps BPSK. Active sometimes.' }]
|
||||
},
|
||||
{
|
||||
keys: ['TEVEL2-4'],
|
||||
displayName: 'TEVEL2-4',
|
||||
type: SAT_TYPE.FM,
|
||||
freqs: [{ downHz: 436400000, upHz: 145970000, mode: 'FM', notes: 'FM / 9600bps BPSK. Active sometimes.' }]
|
||||
},
|
||||
{
|
||||
keys: ['TEVEL2-5'],
|
||||
displayName: 'TEVEL2-5',
|
||||
type: SAT_TYPE.FM,
|
||||
freqs: [{ downHz: 436400000, upHz: 145970000, mode: 'FM', notes: 'FM / 9600bps BPSK. Active sometimes.' }]
|
||||
},
|
||||
{
|
||||
keys: ['TEVEL2-6'],
|
||||
displayName: 'TEVEL2-6',
|
||||
type: SAT_TYPE.FM,
|
||||
freqs: [{ downHz: 436400000, upHz: 145970000, mode: 'FM', notes: 'FM / 9600bps BPSK. Active sometimes.' }]
|
||||
},
|
||||
{
|
||||
keys: ['TEVEL2-7'],
|
||||
displayName: 'TEVEL2-7',
|
||||
type: SAT_TYPE.FM,
|
||||
freqs: [{ downHz: 436400000, upHz: 145970000, mode: 'FM', notes: 'FM / 9600bps BPSK. Active sometimes.' }]
|
||||
},
|
||||
{
|
||||
keys: ['TEVEL2-8'],
|
||||
displayName: 'TEVEL2-8',
|
||||
type: SAT_TYPE.FM,
|
||||
freqs: [{ downHz: 436400000, upHz: 145970000, mode: 'FM', notes: 'FM / 9600bps BPSK. Active sometimes.' }]
|
||||
},
|
||||
{
|
||||
keys: ['TEVEL2-9'],
|
||||
displayName: 'TEVEL2-9',
|
||||
type: SAT_TYPE.FM,
|
||||
freqs: [{ downHz: 436400000, upHz: 145970000, mode: 'FM', notes: 'FM / 9600bps BPSK. Active sometimes.' }]
|
||||
},
|
||||
|
||||
// ── Linear Transponders ────────────────────────────────────────────────────
|
||||
{
|
||||
keys: ['AO-7', 'AO-07'],
|
||||
displayName: 'AO-7',
|
||||
type: SAT_TYPE.LINEAR,
|
||||
freqs: [
|
||||
{ downHz: 145950000, upHz: 432150000, mode: 'LSB', notes: 'Mode B (U/v Inverting) — Up 432.125–432.175 / Down 145.925–145.975. Beacon 29.502 MHz. Sunlight only.' },
|
||||
{ downHz: 29450000, upHz: 145900000, mode: 'USB', notes: 'Mode A (V/a Non-Inverting) — Up 145.850–145.950 / Down 29.400–29.500. Beacon 29.502 MHz. Sunlight only.' },
|
||||
]
|
||||
},
|
||||
{
|
||||
keys: ['AO-73', 'FUNCUBE-1'],
|
||||
displayName: 'AO-73 (FUNcube-1)',
|
||||
type: SAT_TYPE.LINEAR,
|
||||
freqs: [{ downHz: 145960000, upHz: 435140000, mode: 'LSB', notes: 'U/v Inverting — Up 435.130–435.150 / Down 145.950–145.970. Beacon 145.935 MHz. Active in eclipse.' }]
|
||||
},
|
||||
{
|
||||
keys: ['FO-29', 'JAS-2'],
|
||||
displayName: 'FO-29 (JAS-2)',
|
||||
type: SAT_TYPE.LINEAR,
|
||||
freqs: [{ downHz: 435850000, upHz: 145950000, mode: 'LSB', notes: 'V/v Inverting — Up 145.900–146.000 / Down 435.800–435.900. CW Beacon 435.795 MHz. Weekend schedule.' }]
|
||||
},
|
||||
{
|
||||
keys: ['JO-97', 'JY1SAT', 'JY1-SAT'],
|
||||
displayName: 'JO-97 (JY1Sat)',
|
||||
type: SAT_TYPE.LINEAR,
|
||||
freqs: [{ downHz: 145865000, upHz: 435110000, mode: 'LSB', notes: 'U/v Inverting — Up 435.100–435.120 / Down 145.855–145.875. Beacon 145.840 MHz. Active everytime.' }]
|
||||
},
|
||||
{
|
||||
keys: ['RS-44'],
|
||||
displayName: 'RS-44',
|
||||
type: SAT_TYPE.LINEAR,
|
||||
freqs: [{ downHz: 435640000, upHz: 145965000, mode: 'LSB', notes: 'V/v Inverting — Up 145.935–145.995 / Down 435.610–435.670. CW Beacon 435.605 MHz. Active everytime.' }]
|
||||
},
|
||||
{
|
||||
keys: ['TO-108', 'CAS-6', 'TIANQIN-1'],
|
||||
displayName: 'TO-108 (CAS-6)',
|
||||
type: SAT_TYPE.LINEAR,
|
||||
freqs: [{ downHz: 145925000, upHz: 435280000, mode: 'LSB', notes: 'U/v Inverting — Up 435.270–435.290 / Down 145.915–145.935. Beacon 145.910 MHz. Inactive.' }]
|
||||
},
|
||||
{
|
||||
keys: ["QO-100", "ES'HAIL-2", "ESHAIL-2", "P4-A"],
|
||||
displayName: "QO-100 (Es'hail-2)",
|
||||
type: SAT_TYPE.LINEAR,
|
||||
freqs: [{ downHz: 10489750000, upHz: 2400250000, mode: 'USB', notes: "Geostationary 25.9°E — Up 2400.0–2400.5 MHz / Down 10489.5–10490.0 MHz" }]
|
||||
},
|
||||
{
|
||||
keys: ['CAS-4A'],
|
||||
displayName: 'CAS-4A',
|
||||
type: SAT_TYPE.LINEAR,
|
||||
freqs: [{ downHz: 145855000, upHz: 435180000, mode: 'USB', notes: 'Linear. Re-entered.' }]
|
||||
},
|
||||
{
|
||||
keys: ['CAS-4B'],
|
||||
displayName: 'CAS-4B',
|
||||
type: SAT_TYPE.LINEAR,
|
||||
freqs: [{ downHz: 145910000, upHz: 435280000, mode: 'USB', notes: 'Linear. Re-entered.' }]
|
||||
},
|
||||
{
|
||||
keys: ['XW-2A', 'CAS-3A'],
|
||||
displayName: 'XW-2A (CAS-3A)',
|
||||
type: SAT_TYPE.LINEAR,
|
||||
freqs: [{ downHz: 145675000, upHz: 435030000, mode: 'USB', notes: 'Inverting. Re-entered.' }]
|
||||
},
|
||||
{
|
||||
keys: ['XW-2B', 'CAS-3B'],
|
||||
displayName: 'XW-2B (CAS-3B)',
|
||||
type: SAT_TYPE.LINEAR,
|
||||
freqs: [{ downHz: 145730000, upHz: 435090000, mode: 'USB', notes: 'Inverting. Re-entered.' }]
|
||||
},
|
||||
{
|
||||
keys: ['XW-2C', 'CAS-3C'],
|
||||
displayName: 'XW-2C (CAS-3C)',
|
||||
type: SAT_TYPE.LINEAR,
|
||||
freqs: [{ downHz: 145785000, upHz: 435150000, mode: 'USB', notes: 'Inverting. Re-entered.' }]
|
||||
},
|
||||
{
|
||||
keys: ['XW-2D', 'CAS-3D'],
|
||||
displayName: 'XW-2D (CAS-3D)',
|
||||
type: SAT_TYPE.LINEAR,
|
||||
freqs: [{ downHz: 145840000, upHz: 435210000, mode: 'USB', notes: 'Inverting. Re-entered.' }]
|
||||
},
|
||||
{
|
||||
keys: ['XW-2F', 'CAS-3F'],
|
||||
displayName: 'XW-2F (CAS-3F)',
|
||||
type: SAT_TYPE.LINEAR,
|
||||
freqs: [{ downHz: 145945000, upHz: 435330000, mode: 'USB', notes: 'Inverting. Re-entered.' }]
|
||||
},
|
||||
{
|
||||
keys: ['FO-99', 'NEXUS'],
|
||||
displayName: 'FO-99 (NEXUS)',
|
||||
type: SAT_TYPE.LINEAR,
|
||||
freqs: [{ downHz: 435895000, upHz: 145915000, mode: 'LSB', notes: 'V/v Inverting — Up 145.900–145.930 / Down 435.880–435.910. Beacon 437.075 MHz. Re-entered.' }]
|
||||
},
|
||||
{
|
||||
keys: ['HO-113', 'CAS-9', 'XW-3'],
|
||||
displayName: 'HO-113 (CAS-9/XW-3)',
|
||||
type: SAT_TYPE.LINEAR,
|
||||
freqs: [{ downHz: 435180000, upHz: 145870000, mode: 'LSB', notes: 'SSB 4800bps GMSK — Up 145.855–145.885 / Down 435.165–435.195. Beacon 435.575 MHz. Inactive.' }]
|
||||
},
|
||||
{
|
||||
keys: ['CAS-5A', 'FO-118'],
|
||||
displayName: 'CAS-5A (FO-118)',
|
||||
type: SAT_TYPE.LINEAR,
|
||||
freqs: [{ downHz: 435540000, upHz: 145820000, mode: 'USB', notes: 'SSB 30kHz bandwidth. Beacon 435.570 MHz. Re-entered.' }]
|
||||
},
|
||||
{
|
||||
keys: ['CAS-10', 'HO-119', 'XW-4'],
|
||||
displayName: 'CAS-10 (HO-119/XW-4)',
|
||||
type: SAT_TYPE.LINEAR,
|
||||
freqs: [{ downHz: 435180000, upHz: 145870000, mode: 'USB', notes: 'SSB 30kHz bandwidth. Beacon 435.575 MHz. Re-entered.' }]
|
||||
},
|
||||
{
|
||||
keys: ['MESAT-1', 'MO-122'],
|
||||
displayName: 'MESAT-1 (MO-122)',
|
||||
type: SAT_TYPE.LINEAR,
|
||||
freqs: [{ downHz: 435825000, upHz: 145925000, mode: 'LSB', notes: 'Up 145.910–145.940 / Down 435.810–435.840. Beacon 435.800 MHz. Inactive.' }]
|
||||
},
|
||||
{
|
||||
keys: ['AO-109', 'FOX-1E', 'RADFXSAT-2', 'RADFXSAT2'],
|
||||
displayName: 'AO-109 (Fox-1E/RadFxSat-2)',
|
||||
type: SAT_TYPE.LINEAR,
|
||||
freqs: [{ downHz: 435775000, upHz: 145875000, mode: 'LSB', notes: 'Up 145.860–145.890 / Down 435.760–435.790. Beacon 435.750 MHz. Re-entered.' }]
|
||||
},
|
||||
|
||||
// ── APRS / Data ────────────────────────────────────────────────────────────
|
||||
{
|
||||
keys: ['DUCHIFAT-1'],
|
||||
displayName: 'DUCHIFAT-1',
|
||||
type: SAT_TYPE.DATA,
|
||||
freqs: [{ downHz: 145980000, upHz: 0, mode: 'CW/Data', notes: 'Beacon only' }]
|
||||
},
|
||||
]
|
||||
|
||||
// Build lookup map from all keys
|
||||
const LOOKUP = new Map()
|
||||
for (const entry of DB) {
|
||||
for (const key of entry.keys) {
|
||||
LOOKUP.set(key.toUpperCase(), entry)
|
||||
}
|
||||
}
|
||||
|
||||
export function getSatInfo(satName) {
|
||||
if (!satName) return null
|
||||
const upper = satName.toUpperCase().trim()
|
||||
|
||||
// 1. Exact key match
|
||||
if (LOOKUP.has(upper)) return LOOKUP.get(upper)
|
||||
|
||||
// 2. Longest key that is fully contained in the sat name (or vice versa)
|
||||
let best = null
|
||||
let bestLen = 0
|
||||
for (const [key, entry] of LOOKUP) {
|
||||
const inUpper = upper === key
|
||||
|| upper.startsWith(key + ' ') || upper.startsWith(key + '(') || upper.startsWith(key + '-')
|
||||
|| upper.endsWith(' ' + key) || upper.endsWith('(' + key) || upper.endsWith('-' + key)
|
||||
|| upper.includes(' ' + key + ' ') || upper.includes('(' + key + ')')
|
||||
const inKey = key === upper
|
||||
|| key.startsWith(upper + ' ') || key.startsWith(upper + '(') || key.startsWith(upper + '-')
|
||||
|| key.endsWith(' ' + upper) || key.endsWith('(' + upper) || key.endsWith('-' + upper)
|
||||
|| key.includes(' ' + upper + ' ')
|
||||
|
||||
if ((inUpper || inKey) && key.length > bestLen) {
|
||||
best = entry
|
||||
bestLen = key.length
|
||||
}
|
||||
}
|
||||
|
||||
return best
|
||||
}
|
||||
|
||||
export function getFrequencies(satName) {
|
||||
return getSatInfo(satName)?.freqs || null
|
||||
}
|
||||
|
||||
export function getPrimaryFrequency(satName) {
|
||||
return getFrequencies(satName)?.[0] || null
|
||||
}
|
||||
|
||||
export function getSatType(satName) {
|
||||
return getSatInfo(satName)?.type || null
|
||||
}
|
||||
|
||||
export function getDisplayName(satName) {
|
||||
return getSatInfo(satName)?.displayName || satName
|
||||
}
|
||||
86
frontend/src/lib/utils.js
Normal file
86
frontend/src/lib/utils.js
Normal file
@@ -0,0 +1,86 @@
|
||||
// Format Hz to human-readable frequency
|
||||
export function formatFreq(hz) {
|
||||
if (!hz) return '—'
|
||||
if (hz >= 1e9) return (hz / 1e9).toFixed(6) + ' GHz'
|
||||
if (hz >= 1e6) return (hz / 1e6).toFixed(4) + ' MHz'
|
||||
if (hz >= 1e3) return (hz / 1e3).toFixed(2) + ' kHz'
|
||||
return hz.toFixed(0) + ' Hz'
|
||||
}
|
||||
|
||||
// Format Doppler shift
|
||||
export function formatShift(hz) {
|
||||
if (hz === null || hz === undefined) return '—'
|
||||
const sign = hz >= 0 ? '+' : ''
|
||||
if (Math.abs(hz) >= 1000) return `${sign}${(hz / 1000).toFixed(2)} kHz`
|
||||
return `${sign}${hz.toFixed(0)} Hz`
|
||||
}
|
||||
|
||||
// Format azimuth with compass direction
|
||||
export function formatAz(deg) {
|
||||
if (deg === null || deg === undefined) return '—'
|
||||
const dirs = ['N', 'NNE', 'NE', 'ENE', 'E', 'ESE', 'SE', 'SSE',
|
||||
'S', 'SSW', 'SW', 'WSW', 'W', 'WNW', 'NW', 'NNW']
|
||||
const idx = Math.round(deg / 22.5) % 16
|
||||
return `${deg.toFixed(1)}° ${dirs[idx]}`
|
||||
}
|
||||
|
||||
// Format elevation
|
||||
export function formatEl(deg) {
|
||||
if (deg === null || deg === undefined) return '—'
|
||||
return `${deg.toFixed(1)}°`
|
||||
}
|
||||
|
||||
// Format range in km
|
||||
export function formatRange(km) {
|
||||
if (!km) return '—'
|
||||
return `${Math.round(km).toLocaleString('en-US')} km`
|
||||
}
|
||||
|
||||
// Format time to UTC HH:MM:SS
|
||||
export function formatUTC(date) {
|
||||
if (!date) return '—'
|
||||
const d = typeof date === 'string' ? new Date(date) : date
|
||||
return d.toUTCString().slice(17, 25)
|
||||
}
|
||||
|
||||
// Format duration in seconds to mm:ss or hh:mm:ss
|
||||
export function formatDuration(secs) {
|
||||
if (!secs) return '—'
|
||||
const h = Math.floor(secs / 3600)
|
||||
const m = Math.floor((secs % 3600) / 60)
|
||||
const s = Math.floor(secs % 60)
|
||||
if (h > 0) return `${h}:${String(m).padStart(2,'0')}:${String(s).padStart(2,'0')}`
|
||||
return `${String(m).padStart(2,'0')}:${String(s).padStart(2,'0')}`
|
||||
}
|
||||
|
||||
// Time until a future date
|
||||
export function timeUntil(date) {
|
||||
const d = typeof date === 'string' ? new Date(date) : date
|
||||
const diffSecs = Math.floor((d - Date.now()) / 1000)
|
||||
if (diffSecs < 0) return 'passed'
|
||||
if (diffSecs < 60) return `${diffSecs}s`
|
||||
if (diffSecs < 3600) return `${Math.floor(diffSecs/60)}m ${diffSecs%60}s`
|
||||
return `${Math.floor(diffSecs/3600)}h ${Math.floor((diffSecs%3600)/60)}m`
|
||||
}
|
||||
|
||||
// Compute Doppler shift for display
|
||||
export function computeDopplerShift(nominalHz, rangeRateKmS) {
|
||||
const c = 299792.458 // km/s
|
||||
return nominalHz * (-rangeRateKmS / c)
|
||||
}
|
||||
|
||||
// Elevation class for color coding
|
||||
export function elClass(el) {
|
||||
if (el >= 30) return 'el-high'
|
||||
if (el >= 10) return 'el-mid'
|
||||
if (el >= 0) return 'el-low'
|
||||
return 'el-below'
|
||||
}
|
||||
|
||||
// Signal quality based on max elevation
|
||||
export function passQuality(maxEl) {
|
||||
if (maxEl >= 60) return { label: 'Excellent', cls: 'q-excellent' }
|
||||
if (maxEl >= 30) return { label: 'Good', cls: 'q-good' }
|
||||
if (maxEl >= 10) return { label: 'Fair', cls: 'q-fair' }
|
||||
return { label: 'Poor', cls: 'q-poor' }
|
||||
}
|
||||
63
frontend/src/lib/wails.js
Normal file
63
frontend/src/lib/wails.js
Normal file
@@ -0,0 +1,63 @@
|
||||
// Wails runtime bridge
|
||||
// When running in Wails, window.go is injected.
|
||||
// For dev (browser), we stub the calls.
|
||||
|
||||
const isWails = typeof window !== 'undefined' && window.go !== undefined
|
||||
|
||||
function wailsCall(path, ...args) {
|
||||
if (!isWails) {
|
||||
console.warn('[Wails stub]', path, args)
|
||||
return Promise.resolve(null)
|
||||
}
|
||||
// path like "main.App.GetSatelliteList"
|
||||
const parts = path.split('.')
|
||||
let fn = window.go
|
||||
for (const p of parts) {
|
||||
fn = fn[p]
|
||||
if (!fn) {
|
||||
console.error('[Wails] method not found:', path)
|
||||
return Promise.resolve(null)
|
||||
}
|
||||
}
|
||||
return fn(...args)
|
||||
}
|
||||
|
||||
export const Go = {
|
||||
GetSatelliteList: () => wailsCall('main.App.GetSatelliteList'),
|
||||
GetPasses: (name, hours) => wailsCall('main.App.GetPasses', name, hours),
|
||||
GetCurrentPosition: (name) => wailsCall('main.App.GetCurrentPosition', name),
|
||||
SetObserverLocation: (lat, lon, alt) => wailsCall('main.App.SetObserverLocation', lat, lon, alt),
|
||||
SetSatelliteFrequencies: (down, up) => wailsCall('main.App.SetSatelliteFrequencies', down, up),
|
||||
StartTracking: (name) => wailsCall('main.App.StartTracking', name),
|
||||
StopTracking: () => wailsCall('main.App.StopTracking'),
|
||||
ConnectFlexRadio: (host, port) => wailsCall('main.App.ConnectFlexRadio', host, port),
|
||||
DisconnectFlexRadio: () => wailsCall('main.App.DisconnectFlexRadio'),
|
||||
ConnectRotor: (host, port) => wailsCall('main.App.ConnectRotor', host, port),
|
||||
DisconnectRotor: () => wailsCall('main.App.DisconnectRotor'),
|
||||
RefreshTLE: () => wailsCall('main.App.RefreshTLE'),
|
||||
GetFlexRadioStatus: () => wailsCall('main.App.GetFlexRadioStatus'),
|
||||
GetRotorStatus: () => wailsCall('main.App.GetRotorStatus'),
|
||||
GetTLEAge: () => wailsCall('main.App.GetTLEAge'),
|
||||
GetWatchlist: () => wailsCall('main.App.GetWatchlist'),
|
||||
SetWatchlist: (names) => wailsCall('main.App.SetWatchlist', names),
|
||||
GetGroundtrack: (name, minutes) => wailsCall('main.App.GetGroundtrack', name, minutes),
|
||||
GetSliceConfig: () => wailsCall('main.App.GetSliceConfig'),
|
||||
SetDopplerEnabled: (v) => wailsCall('main.App.SetDopplerEnabled', v),
|
||||
SetRotorEnabled: (v) => wailsCall('main.App.SetRotorEnabled', v),
|
||||
SetRotorAzOnly: (v) => wailsCall('main.App.SetRotorAzOnly', v),
|
||||
GetRotorEnabled: () => wailsCall('main.App.GetRotorEnabled'),
|
||||
GetDopplerEnabled: () => wailsCall('main.App.GetDopplerEnabled'),
|
||||
SetSliceConfig: (rx, tx) => wailsCall('main.App.SetSliceConfig', rx, tx),
|
||||
SetSatelliteMode: (mode) => wailsCall('main.App.SetSatelliteMode', mode),
|
||||
SetTrackFreqMode: (v) => wailsCall('main.App.SetTrackFreqMode', v),
|
||||
SetTrackAzimuth: (v) => wailsCall('main.App.SetTrackAzimuth', v),
|
||||
}
|
||||
|
||||
// Wails event listener wrapper
|
||||
export function onWailsEvent(event, callback) {
|
||||
if (isWails && window.runtime) {
|
||||
window.runtime.EventsOn(event, callback)
|
||||
return () => window.runtime.EventsOff(event)
|
||||
}
|
||||
return () => {}
|
||||
}
|
||||
7
frontend/src/main.js
Normal file
7
frontend/src/main.js
Normal file
@@ -0,0 +1,7 @@
|
||||
import App from './App.svelte'
|
||||
|
||||
const app = new App({
|
||||
target: document.getElementById('app')
|
||||
})
|
||||
|
||||
export default app
|
||||
136
frontend/src/stores/satstore.js
Normal file
136
frontend/src/stores/satstore.js
Normal file
@@ -0,0 +1,136 @@
|
||||
import { writable } from 'svelte/store'
|
||||
|
||||
// All satellite positions (updated every second from backend)
|
||||
export const satPositions = writable([])
|
||||
|
||||
// List of available satellite names
|
||||
export const satList = writable([])
|
||||
|
||||
// Currently selected/tracked satellite name
|
||||
export const trackedSat = writable('')
|
||||
|
||||
// Upcoming passes for tracked satellite
|
||||
export const passes = writable([])
|
||||
|
||||
// Current position of tracked satellite - derived + refreshed every tick
|
||||
export const trackedPosition = writable(null)
|
||||
|
||||
// Update trackedPosition whenever positions update
|
||||
let _lastPositions = []
|
||||
let _lastTracked = ''
|
||||
|
||||
satPositions.subscribe(positions => {
|
||||
_lastPositions = positions
|
||||
if (!_lastTracked) { trackedPosition.set(null); return }
|
||||
const pos = positions.find(p => p.name === _lastTracked) || null
|
||||
trackedPosition.set(pos)
|
||||
})
|
||||
|
||||
trackedSat.subscribe(name => {
|
||||
_lastTracked = name
|
||||
if (!name) { trackedPosition.set(null); return }
|
||||
const pos = _lastPositions.find(p => p.name === name) || null
|
||||
trackedPosition.set(pos)
|
||||
})
|
||||
|
||||
// Connection states
|
||||
export const flexConnected = writable(false)
|
||||
export const dopplerEnabled = writable(true)
|
||||
|
||||
// Passes panel state — persisted in memory so tab switches don't reset filters
|
||||
export const passesViewMode = writable('single') // 'single' | 'all'
|
||||
export const passesMinEl = writable(0) // 0 | 10 | 30 | 60
|
||||
export const passesAllCache = writable([]) // cached all-watchlist passes
|
||||
export const rotorEnabled = writable(true)
|
||||
export const soundEnabled = writable(true) // AOS voice alerts
|
||||
|
||||
// Per-session tracking toggles (not persisted — reset on startup)
|
||||
export const trackFreqMode = writable(false) // Track frequency+mode for current sat
|
||||
export const trackAzimuth = writable(false) // Track azimuth for current sat
|
||||
|
||||
export const rotorConnected = writable(false)
|
||||
|
||||
// Default settings
|
||||
const DEFAULT_SETTINGS = {
|
||||
qthLat: 48.7,
|
||||
qthLon: 2.55,
|
||||
qthAlt: 100,
|
||||
flexHost: '192.168.1.100',
|
||||
flexPort: 4992,
|
||||
rotorHost: '127.0.0.1',
|
||||
rotorPort: 12000,
|
||||
rotorAzOnly: true, // true = azimuth only, false = az+el
|
||||
downlinkHz: 145800000,
|
||||
uplinkHz: 145200000,
|
||||
minElFilter: 5,
|
||||
autoConnectFlex: false,
|
||||
autoConnectRotor: false,
|
||||
rxSlice: 0, // Slice A = RX downlink
|
||||
txSlice: 1, // Slice B = TX uplink
|
||||
}
|
||||
|
||||
// Settings version — bump this to force reset of specific fields
|
||||
const SETTINGS_VERSION = 3
|
||||
|
||||
// Load persisted settings from localStorage
|
||||
function loadSettings() {
|
||||
try {
|
||||
const saved = localStorage.getItem('satmaster_settings')
|
||||
if (saved) {
|
||||
const parsed = JSON.parse(saved)
|
||||
// If version mismatch, force slice config reset to new defaults
|
||||
if (parsed._version !== SETTINGS_VERSION) {
|
||||
return {
|
||||
...DEFAULT_SETTINGS,
|
||||
...parsed,
|
||||
rxSlice: DEFAULT_SETTINGS.rxSlice,
|
||||
txSlice: DEFAULT_SETTINGS.txSlice,
|
||||
_version: SETTINGS_VERSION,
|
||||
}
|
||||
}
|
||||
return { ...DEFAULT_SETTINGS, ...parsed }
|
||||
}
|
||||
} catch(e) {}
|
||||
return { ...DEFAULT_SETTINGS, _version: SETTINGS_VERSION }
|
||||
}
|
||||
|
||||
// Persist settings to localStorage on every change
|
||||
function createPersistedSettings() {
|
||||
const store = writable(loadSettings())
|
||||
store.subscribe(val => {
|
||||
try {
|
||||
localStorage.setItem('satmaster_settings', JSON.stringify(val))
|
||||
} catch(e) {}
|
||||
})
|
||||
return store
|
||||
}
|
||||
|
||||
export const settings = createPersistedSettings()
|
||||
|
||||
// Watchlist - satellites to display on map (persisted)
|
||||
function createPersistedWatchlist() {
|
||||
const DEFAULT = ['ISS (ZARYA)','AO-7','AO-27','SO-50','RS-44','AO-91','FO-29']
|
||||
let initial = DEFAULT
|
||||
try {
|
||||
const saved = localStorage.getItem('satmaster_watchlist')
|
||||
if (saved) initial = JSON.parse(saved)
|
||||
} catch(e) {}
|
||||
const store = writable(initial)
|
||||
store.subscribe(val => {
|
||||
try { localStorage.setItem('satmaster_watchlist', JSON.stringify(val)) } catch(e) {}
|
||||
})
|
||||
return store
|
||||
}
|
||||
export const watchlist = createPersistedWatchlist()
|
||||
|
||||
// TLE info
|
||||
export const tleAge = writable(null)
|
||||
export const tleLoaded = writable(false)
|
||||
|
||||
// Doppler display values
|
||||
export const dopplerInfo = writable({
|
||||
downShiftHz: 0,
|
||||
upShiftHz: 0,
|
||||
correctedDownHz: 0,
|
||||
correctedUpHz: 0,
|
||||
})
|
||||
Reference in New Issue
Block a user