first commit

This commit is contained in:
2026-03-24 23:24:36 +01:00
commit a69394a05b
1638 changed files with 891299 additions and 0 deletions

267
frontend/src/App.svelte Normal file
View 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>

View 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>

View 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>

View 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>

View 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>

View 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>

View 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>

View 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
View 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/2002400bps 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/2002400bps 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 509600bps. 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 / 12009600bps 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.125432.175 / Down 145.925145.975. Beacon 29.502 MHz. Sunlight only.' },
{ downHz: 29450000, upHz: 145900000, mode: 'USB', notes: 'Mode A (V/a Non-Inverting) — Up 145.850145.950 / Down 29.40029.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.130435.150 / Down 145.950145.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.900146.000 / Down 435.800435.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.100435.120 / Down 145.855145.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.935145.995 / Down 435.610435.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.270435.290 / Down 145.915145.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.02400.5 MHz / Down 10489.510490.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.900145.930 / Down 435.880435.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.855145.885 / Down 435.165435.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.910145.940 / Down 435.810435.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.860145.890 / Down 435.760435.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
View 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
View 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
View File

@@ -0,0 +1,7 @@
import App from './App.svelte'
const app = new App({
target: document.getElementById('app')
})
export default app

View 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,
})