Files
intercept/templates/satellite_dashboard.html
James Smith 43ff1a8b74 Fix satellite marker disappearing when changing passes
When removing map layers, set references to null so new markers get
created properly on position updates. Also clear orbit track lines
when changing passes.

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2025-12-30 15:35:49 +00:00

1663 lines
61 KiB
HTML

<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>SATELLITE COMMAND // INTERCEPT</title>
<link href="https://fonts.googleapis.com/css2?family=Orbitron:wght@400;500;700;900&family=Rajdhani:wght@300;400;500;600;700&family=JetBrains+Mono:wght@300;400;500&display=swap" rel="stylesheet">
<link rel="stylesheet" href="https://unpkg.com/leaflet@1.9.4/dist/leaflet.css" />
<script src="https://unpkg.com/leaflet@1.9.4/dist/leaflet.js"></script>
<style>
* {
margin: 0;
padding: 0;
box-sizing: border-box;
}
:root {
--bg-dark: #0a0a0f;
--bg-panel: #0d1117;
--bg-card: #161b22;
--border-glow: #00d4ff;
--text-primary: #e6edf3;
--text-secondary: #8b949e;
--accent-cyan: #00d4ff;
--accent-green: #00ff88;
--accent-orange: #ff9500;
--accent-red: #ff4444;
--accent-purple: #a855f7;
--grid-line: rgba(0, 212, 255, 0.1);
}
body {
font-family: 'Rajdhani', sans-serif;
background: var(--bg-dark);
color: var(--text-primary);
min-height: 100vh;
overflow-x: hidden;
}
/* Animated grid background */
.grid-bg {
position: fixed;
top: 0;
left: 0;
right: 0;
bottom: 0;
background-image:
linear-gradient(var(--grid-line) 1px, transparent 1px),
linear-gradient(90deg, var(--grid-line) 1px, transparent 1px);
background-size: 50px 50px;
animation: gridMove 20s linear infinite;
pointer-events: none;
z-index: 0;
}
@keyframes gridMove {
0% { transform: translate(0, 0); }
100% { transform: translate(50px, 50px); }
}
/* Scan line effect */
.scanline {
position: fixed;
top: 0;
left: 0;
right: 0;
height: 4px;
background: linear-gradient(90deg, transparent, var(--accent-cyan), transparent);
animation: scan 3s linear infinite;
pointer-events: none;
z-index: 1000;
opacity: 0.5;
}
@keyframes scan {
0% { top: -4px; }
100% { top: 100vh; }
}
/* Header */
.header {
position: relative;
z-index: 10;
padding: 15px 30px;
background: linear-gradient(180deg, rgba(0,212,255,0.1) 0%, transparent 100%);
border-bottom: 1px solid rgba(0,212,255,0.3);
display: flex;
justify-content: space-between;
align-items: center;
}
.logo {
font-family: 'Orbitron', monospace;
font-size: 28px;
font-weight: 900;
letter-spacing: 4px;
color: var(--accent-cyan);
text-shadow: 0 0 20px var(--accent-cyan), 0 0 40px var(--accent-cyan);
}
.logo span {
color: var(--text-secondary);
font-weight: 400;
font-size: 16px;
margin-left: 15px;
letter-spacing: 2px;
}
.status-bar {
display: flex;
gap: 30px;
font-family: 'JetBrains Mono', monospace;
font-size: 12px;
}
.status-item {
display: flex;
align-items: center;
gap: 8px;
}
.status-dot {
width: 8px;
height: 8px;
border-radius: 50%;
background: var(--accent-green);
box-shadow: 0 0 10px var(--accent-green);
animation: pulse 2s ease-in-out infinite;
}
@keyframes pulse {
0%, 100% { opacity: 1; }
50% { opacity: 0.5; }
}
.datetime {
font-family: 'Orbitron', monospace;
font-size: 14px;
color: var(--accent-cyan);
}
/* Satellite selector */
.satellite-selector {
display: flex;
align-items: center;
gap: 12px;
background: rgba(0,0,0,0.3);
padding: 8px 16px;
border-radius: 6px;
border: 1px solid rgba(0,212,255,0.3);
}
.satellite-selector label {
font-family: 'Orbitron', monospace;
font-size: 11px;
font-weight: 500;
letter-spacing: 2px;
color: var(--text-secondary);
}
.satellite-selector select {
background: rgba(0,212,255,0.1);
border: 1px solid var(--accent-cyan);
border-radius: 4px;
padding: 8px 30px 8px 12px;
color: var(--accent-cyan);
font-family: 'Orbitron', monospace;
font-size: 13px;
font-weight: 600;
letter-spacing: 1px;
cursor: pointer;
appearance: none;
background-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='12' height='12' viewBox='0 0 12 12'%3E%3Cpath fill='%2300d4ff' d='M6 8L2 4h8z'/%3E%3C/svg%3E");
background-repeat: no-repeat;
background-position: right 10px center;
min-width: 180px;
}
.satellite-selector select:hover {
background-color: rgba(0,212,255,0.2);
box-shadow: 0 0 15px rgba(0,212,255,0.3);
}
.satellite-selector select:focus {
outline: none;
box-shadow: 0 0 20px rgba(0,212,255,0.4);
}
.satellite-selector select option {
background: var(--bg-panel);
color: var(--text-primary);
padding: 10px;
}
/* Main dashboard grid */
.dashboard {
position: relative;
z-index: 10;
display: grid;
grid-template-columns: 1fr 1fr 380px;
grid-template-rows: 1fr;
gap: 20px;
padding: 20px;
height: calc(100vh - 80px);
min-height: 600px;
}
/* Panels */
.panel {
background: var(--bg-panel);
border: 1px solid rgba(0,212,255,0.2);
border-radius: 8px;
overflow: hidden;
position: relative;
}
.panel::before {
content: '';
position: absolute;
top: 0;
left: 0;
right: 0;
height: 2px;
background: linear-gradient(90deg, transparent, var(--accent-cyan), transparent);
}
.panel-header {
padding: 12px 20px;
background: rgba(0,212,255,0.05);
border-bottom: 1px solid rgba(0,212,255,0.1);
font-family: 'Orbitron', monospace;
font-size: 12px;
font-weight: 500;
letter-spacing: 2px;
text-transform: uppercase;
color: var(--accent-cyan);
display: flex;
justify-content: space-between;
align-items: center;
}
.panel-indicator {
width: 6px;
height: 6px;
background: var(--accent-green);
border-radius: 50%;
animation: blink 1s ease-in-out infinite;
}
@keyframes blink {
0%, 100% { opacity: 1; }
50% { opacity: 0.3; }
}
.panel-content {
padding: 15px;
height: calc(100% - 45px);
overflow-y: auto;
}
/* Polar plot */
.polar-container {
grid-column: 1;
grid-row: 1;
}
#polarPlot {
width: 100%;
height: 100%;
min-height: 400px;
}
/* Ground track map */
.map-container {
grid-column: 2;
grid-row: 1;
}
#groundMap {
width: 100%;
height: 100%;
min-height: 400px;
border-radius: 4px;
}
/* Right sidebar */
.sidebar {
grid-column: 3;
grid-row: 1;
display: flex;
flex-direction: column;
gap: 12px;
overflow-y: auto;
padding-right: 5px;
}
.sidebar .panel {
flex-shrink: 0;
}
/* Countdown panel */
.countdown-panel {
background: linear-gradient(135deg, rgba(0,212,255,0.1) 0%, rgba(0,255,136,0.05) 100%);
}
.countdown-display {
text-align: center;
padding: 20px 10px;
}
.next-pass-label {
font-size: 11px;
text-transform: uppercase;
letter-spacing: 2px;
color: var(--text-secondary);
margin-bottom: 5px;
}
.satellite-name {
font-family: 'Orbitron', monospace;
font-size: 20px;
font-weight: 700;
color: var(--accent-cyan);
text-shadow: 0 0 15px var(--accent-cyan);
margin-bottom: 15px;
}
.countdown-grid {
display: grid;
grid-template-columns: repeat(4, 1fr);
gap: 8px;
}
.countdown-block {
background: rgba(0,0,0,0.3);
border: 1px solid rgba(0,212,255,0.2);
border-radius: 6px;
padding: 10px 5px;
text-align: center;
}
.countdown-value {
font-family: 'Orbitron', monospace;
font-size: 28px;
font-weight: 700;
color: var(--accent-cyan);
text-shadow: 0 0 10px var(--accent-cyan);
line-height: 1;
}
.countdown-value.active {
color: var(--accent-green);
text-shadow: 0 0 15px var(--accent-green);
animation: countPulse 1s ease-in-out infinite;
}
@keyframes countPulse {
0%, 100% { opacity: 1; transform: scale(1); }
50% { opacity: 0.8; transform: scale(1.05); }
}
.countdown-label {
font-size: 10px;
text-transform: uppercase;
letter-spacing: 1px;
color: var(--text-secondary);
margin-top: 5px;
}
/* Stats panel */
.stats-grid {
display: grid;
grid-template-columns: 1fr 1fr;
gap: 10px;
}
.stat-box {
background: rgba(0,0,0,0.3);
border: 1px solid rgba(0,212,255,0.15);
border-radius: 6px;
padding: 12px;
text-align: center;
}
.stat-value {
font-family: 'Orbitron', monospace;
font-size: 22px;
font-weight: 600;
color: var(--accent-cyan);
}
.stat-value.highlight {
color: var(--accent-green);
}
.stat-label {
font-size: 10px;
text-transform: uppercase;
letter-spacing: 1px;
color: var(--text-secondary);
margin-top: 4px;
}
/* Pass list */
.pass-list {
flex: 1;
overflow-y: auto;
}
.pass-list-content {
display: flex;
flex-direction: column;
gap: 8px;
min-height: 200px;
max-height: 400px;
overflow-y: auto;
}
.pass-item {
background: rgba(0,0,0,0.3);
border: 1px solid rgba(0,212,255,0.15);
border-radius: 6px;
padding: 10px;
cursor: pointer;
transition: all 0.2s ease;
}
.pass-item:hover {
border-color: var(--accent-cyan);
background: rgba(0,212,255,0.05);
}
.pass-item.active {
border-color: var(--accent-cyan);
box-shadow: 0 0 15px rgba(0,212,255,0.2);
background: rgba(0,212,255,0.1);
}
.pass-item-header {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 8px;
}
.pass-sat-name {
font-family: 'Orbitron', monospace;
font-size: 13px;
font-weight: 600;
color: var(--accent-cyan);
}
.pass-quality {
font-size: 9px;
padding: 2px 6px;
border-radius: 3px;
text-transform: uppercase;
letter-spacing: 1px;
font-weight: 600;
}
.pass-quality.excellent {
background: rgba(0,255,136,0.2);
color: var(--accent-green);
}
.pass-quality.good {
background: rgba(0,212,255,0.2);
color: var(--accent-cyan);
}
.pass-quality.fair {
background: rgba(255,149,0,0.2);
color: var(--accent-orange);
}
.pass-item-details {
display: flex;
justify-content: space-between;
font-size: 11px;
color: var(--text-secondary);
}
.pass-time {
font-family: 'JetBrains Mono', monospace;
}
/* Telemetry panel */
.telemetry-panel {
background: linear-gradient(135deg, rgba(168,85,247,0.1) 0%, rgba(0,212,255,0.05) 100%);
}
.telemetry-rows {
display: flex;
flex-direction: column;
gap: 8px;
}
.telemetry-row {
display: flex;
justify-content: space-between;
align-items: center;
padding: 8px 12px;
background: rgba(0,0,0,0.2);
border-radius: 4px;
border-left: 2px solid var(--accent-cyan);
}
.telemetry-label {
font-size: 11px;
text-transform: uppercase;
letter-spacing: 1px;
color: var(--text-secondary);
}
.telemetry-value {
font-family: 'JetBrains Mono', monospace;
font-size: 14px;
color: var(--accent-cyan);
white-space: nowrap;
}
/* Observer location */
.observer-panel .panel-content {
padding: 10px 15px;
}
.observer-grid {
display: grid;
grid-template-columns: 1fr 1fr;
gap: 10px;
}
.observer-input {
display: flex;
flex-direction: column;
gap: 4px;
}
.observer-input label {
font-size: 10px;
text-transform: uppercase;
letter-spacing: 1px;
color: var(--text-secondary);
}
.observer-input input {
background: rgba(0,0,0,0.3);
border: 1px solid rgba(0,212,255,0.2);
border-radius: 4px;
padding: 8px 10px;
color: var(--text-primary);
font-family: 'JetBrains Mono', monospace;
font-size: 12px;
}
.observer-input input:focus {
outline: none;
border-color: var(--accent-cyan);
box-shadow: 0 0 10px rgba(0,212,255,0.2);
}
.observer-actions {
display: flex;
gap: 8px;
margin-top: 10px;
}
.btn {
flex: 1;
padding: 10px;
border: 1px solid var(--accent-cyan);
background: rgba(0,212,255,0.1);
color: var(--accent-cyan);
font-family: 'Rajdhani', sans-serif;
font-size: 12px;
font-weight: 600;
text-transform: uppercase;
letter-spacing: 1px;
border-radius: 4px;
cursor: pointer;
transition: all 0.2s ease;
}
.btn:hover {
background: var(--accent-cyan);
color: var(--bg-dark);
box-shadow: 0 0 20px rgba(0,212,255,0.3);
}
.btn.primary {
background: var(--accent-cyan);
color: var(--bg-dark);
}
.btn.primary:hover {
box-shadow: 0 0 25px rgba(0,212,255,0.5);
}
/* Leaflet dark theme overrides */
.leaflet-container {
background: var(--bg-dark) !important;
}
.leaflet-control-zoom a {
background: var(--bg-panel) !important;
color: var(--accent-cyan) !important;
border-color: rgba(0,212,255,0.3) !important;
}
.leaflet-control-attribution {
background: rgba(0,0,0,0.7) !important;
color: var(--text-secondary) !important;
font-size: 9px !important;
}
/* Custom scrollbar */
::-webkit-scrollbar {
width: 6px;
}
::-webkit-scrollbar-track {
background: var(--bg-dark);
}
::-webkit-scrollbar-thumb {
background: var(--accent-cyan);
border-radius: 3px;
}
/* Responsive - only kick in on smaller screens */
@media (max-width: 1200px) {
.dashboard {
grid-template-columns: 1fr 1fr;
grid-template-rows: 1fr auto;
height: auto;
min-height: 100vh;
}
.polar-container {
grid-column: 1;
grid-row: 1;
}
.map-container {
grid-column: 2;
grid-row: 1;
}
.sidebar {
grid-column: 1 / 3;
grid-row: 2;
flex-direction: row;
flex-wrap: wrap;
overflow-y: visible;
max-height: none;
}
.sidebar .panel {
flex: 1 1 280px;
max-height: 280px;
}
}
@media (max-width: 800px) {
.dashboard {
grid-template-columns: 1fr;
grid-template-rows: auto;
}
.polar-container, .map-container {
grid-column: 1;
min-height: 300px;
}
.sidebar {
grid-column: 1;
flex-direction: column;
}
.sidebar .panel {
flex: none;
max-height: none;
min-width: 100%;
}
}
</style>
</head>
<body>
<div class="grid-bg"></div>
<div class="scanline"></div>
<header class="header">
<div class="logo">
SATELLITE COMMAND
<span>// INTERCEPT</span>
</div>
<div class="satellite-selector">
<label for="satSelect">TARGET:</label>
<select id="satSelect" onchange="onSatelliteChange()">
<option value="25544">ISS (ZARYA)</option>
<option value="25338">NOAA 15</option>
<option value="28654">NOAA 18</option>
<option value="33591">NOAA 19</option>
<option value="40069">METEOR-M2</option>
<option value="43013">NOAA 20</option>
<option value="54234">METEOR-M2-3</option>
</select>
</div>
<div class="status-bar">
<div class="status-item">
<div class="status-dot" id="trackingDot"></div>
<span id="trackingStatus">TRACKING ACTIVE</span>
</div>
<div class="status-item datetime" id="utcTime">--:--:-- UTC</div>
<a href="/?mode=satellite" style="color: var(--accent-cyan); text-decoration: none; font-size: 12px; padding: 4px 12px; border: 1px solid var(--accent-cyan); border-radius: 4px; margin-left: 10px;">← Main Dashboard</a>
</div>
</header>
<main class="dashboard">
<!-- Polar Plot -->
<div class="panel polar-container">
<div class="panel-header">
<span>SKY VIEW // POLAR PLOT</span>
<div class="panel-indicator"></div>
</div>
<div class="panel-content">
<canvas id="polarPlot"></canvas>
</div>
</div>
<!-- Ground Track Map -->
<div class="panel map-container">
<div class="panel-header">
<span>GROUND TRACK // WORLD VIEW</span>
<div class="panel-indicator"></div>
</div>
<div class="panel-content">
<div id="groundMap"></div>
</div>
</div>
<!-- Sidebar -->
<div class="sidebar">
<!-- Countdown -->
<div class="panel countdown-panel">
<div class="panel-header">
<span>NEXT PASS</span>
<div class="panel-indicator"></div>
</div>
<div class="countdown-display">
<div class="next-pass-label">Incoming Signal</div>
<div class="satellite-name" id="countdownSat">AWAITING DATA</div>
<div class="countdown-grid">
<div class="countdown-block">
<div class="countdown-value" id="countDays">--</div>
<div class="countdown-label">Days</div>
</div>
<div class="countdown-block">
<div class="countdown-value" id="countHours">--</div>
<div class="countdown-label">Hours</div>
</div>
<div class="countdown-block">
<div class="countdown-value" id="countMins">--</div>
<div class="countdown-label">Mins</div>
</div>
<div class="countdown-block">
<div class="countdown-value" id="countSecs">--</div>
<div class="countdown-label">Secs</div>
</div>
</div>
</div>
</div>
<!-- Stats -->
<div class="panel">
<div class="panel-header">
<span>TRACKING STATS</span>
<div class="panel-indicator"></div>
</div>
<div class="panel-content">
<div class="stats-grid">
<div class="stat-box">
<div class="stat-value" id="statTracked">0</div>
<div class="stat-label">Satellites</div>
</div>
<div class="stat-box">
<div class="stat-value highlight" id="statVisible">0</div>
<div class="stat-label">Visible Now</div>
</div>
<div class="stat-box">
<div class="stat-value" id="statPasses">0</div>
<div class="stat-label">Passes Today</div>
</div>
<div class="stat-box">
<div class="stat-value" id="statMaxEl"></div>
<div class="stat-label">Best Elevation</div>
</div>
</div>
</div>
</div>
<!-- Pass List -->
<div class="panel pass-list">
<div class="panel-header">
<span>UPCOMING PASSES <span id="passCount" style="color: var(--accent-cyan);"></span></span>
<div class="panel-indicator"></div>
</div>
<div class="panel-content">
<div class="pass-list-content" id="passList">
<div style="text-align:center;color:var(--text-secondary);padding:20px;">
Calculating passes...
</div>
</div>
</div>
</div>
<!-- Telemetry -->
<div class="panel telemetry-panel">
<div class="panel-header">
<span>LIVE TELEMETRY</span>
<div class="panel-indicator"></div>
</div>
<div class="panel-content">
<div class="telemetry-rows">
<div class="telemetry-row">
<span class="telemetry-label">Latitude</span>
<span class="telemetry-value" id="telLat">---.----°</span>
</div>
<div class="telemetry-row">
<span class="telemetry-label">Longitude</span>
<span class="telemetry-value" id="telLon">---.----°</span>
</div>
<div class="telemetry-row">
<span class="telemetry-label">Altitude</span>
<span class="telemetry-value" id="telAlt">--- km</span>
</div>
<div class="telemetry-row">
<span class="telemetry-label">Elevation</span>
<span class="telemetry-value" id="telEl">--.-°</span>
</div>
<div class="telemetry-row">
<span class="telemetry-label">Azimuth</span>
<span class="telemetry-value" id="telAz">---.-°</span>
</div>
<div class="telemetry-row">
<span class="telemetry-label">Distance</span>
<span class="telemetry-value" id="telDist">---- km</span>
</div>
</div>
</div>
</div>
<!-- Observer -->
<div class="panel observer-panel">
<div class="panel-header">
<span>OBSERVER LOCATION</span>
<div class="panel-indicator"></div>
</div>
<div class="panel-content">
<div class="observer-grid">
<div class="observer-input">
<label>Latitude</label>
<input type="number" id="obsLat" value="51.5074" step="0.0001">
</div>
<div class="observer-input">
<label>Longitude</label>
<input type="number" id="obsLon" value="-0.1278" step="0.0001">
</div>
</div>
<div class="observer-actions">
<button class="btn" onclick="getLocation()">📍 GPS</button>
<button class="btn primary" onclick="calculatePasses()">CALCULATE</button>
</div>
</div>
</div>
</div>
</main>
<script>
// Dashboard state
let passes = [];
let selectedPass = null;
let groundMap = null;
let satMarker = null;
let trackLine = null;
let observerMarker = null;
let orbitTrack = null;
let selectedSatellite = 25544; // Default to ISS
const satellites = {
25544: { name: 'ISS (ZARYA)', color: '#00ffff' },
25338: { name: 'NOAA 15', color: '#00ff00' },
28654: { name: 'NOAA 18', color: '#ff6600' },
33591: { name: 'NOAA 19', color: '#ff3366' },
40069: { name: 'METEOR-M2', color: '#9370DB' },
43013: { name: 'NOAA 20', color: '#00ffaa' },
54234: { name: 'METEOR-M2-3', color: '#ff00ff' }
};
// Handle satellite selection change
function onSatelliteChange() {
const select = document.getElementById('satSelect');
selectedSatellite = parseInt(select.value);
const satName = satellites[selectedSatellite]?.name || 'Unknown';
// Update tracking status
document.getElementById('trackingStatus').textContent = 'ACQUIRING ' + satName;
document.getElementById('trackingDot').style.background = 'var(--accent-orange)';
// Recalculate passes for new satellite
calculatePasses();
// Update real-time position immediately
updateRealTimePositions();
}
// Initialize dashboard
document.addEventListener('DOMContentLoaded', () => {
initGroundMap();
updateClock();
setInterval(updateClock, 1000);
setInterval(updateCountdown, 1000);
setInterval(updateRealTimePositions, 5000);
// Try to get location, then calculate
getLocation();
});
function updateClock() {
const now = new Date();
document.getElementById('utcTime').textContent =
now.toISOString().substring(11, 19) + ' UTC';
}
function initGroundMap() {
groundMap = L.map('groundMap', {
center: [20, 0],
zoom: 2,
minZoom: 1,
maxZoom: 10,
worldCopyJump: true
});
L.tileLayer('https://{s}.basemaps.cartocdn.com/dark_all/{z}/{x}/{y}{r}.png', {
attribution: '©OpenStreetMap, ©CartoDB'
}).addTo(groundMap);
}
function getLocation() {
if (navigator.geolocation) {
navigator.geolocation.getCurrentPosition(pos => {
document.getElementById('obsLat').value = pos.coords.latitude.toFixed(4);
document.getElementById('obsLon').value = pos.coords.longitude.toFixed(4);
calculatePasses();
}, () => {
calculatePasses();
});
} else {
calculatePasses();
}
}
async function calculatePasses() {
const lat = parseFloat(document.getElementById('obsLat').value);
const lon = parseFloat(document.getElementById('obsLon').value);
const satName = satellites[selectedSatellite]?.name || 'Unknown';
try {
const response = await fetch('/satellite/predict', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
latitude: lat,
longitude: lon,
hours: 48,
minEl: 5,
satellites: [selectedSatellite]
})
});
const data = await response.json();
if (data.status === 'success') {
passes = data.passes;
renderPassList();
updateStats();
if (passes.length > 0) {
selectPass(0);
}
updateObserverMarker(lat, lon);
// Update tracking status
document.getElementById('trackingStatus').textContent = 'TRACKING ' + satName;
document.getElementById('trackingDot').style.background = 'var(--accent-green)';
} else {
document.getElementById('trackingStatus').textContent = 'ERROR: ' + (data.message || 'Unknown');
document.getElementById('trackingDot').style.background = 'var(--accent-red)';
}
} catch (err) {
console.error('Pass calculation error:', err);
document.getElementById('trackingStatus').textContent = 'CONNECTION ERROR';
document.getElementById('trackingDot').style.background = 'var(--accent-red)';
}
}
function renderPassList() {
const container = document.getElementById('passList');
const countEl = document.getElementById('passCount');
if (passes.length === 0) {
container.innerHTML = '<div style="text-align:center;color:var(--text-secondary);padding:20px;">No passes found</div>';
if (countEl) countEl.textContent = '';
return;
}
// Show count in header
if (countEl) countEl.textContent = `(${passes.length})`;
container.innerHTML = passes.slice(0, 10).map((pass, idx) => {
const quality = pass.maxEl >= 60 ? 'excellent' : pass.maxEl >= 30 ? 'good' : 'fair';
const qualityText = pass.maxEl >= 60 ? 'EXCELLENT' : pass.maxEl >= 30 ? 'GOOD' : 'FAIR';
const time = pass.startTime.split(' ')[1] || pass.startTime; // Extract time portion
return `
<div class="pass-item ${selectedPass === idx ? 'active' : ''}" onclick="selectPass(${idx})">
<div class="pass-item-header">
<span class="pass-sat-name">${pass.satellite}</span>
<span class="pass-quality ${quality}">${qualityText}</span>
</div>
<div class="pass-item-details">
<span class="pass-time">${time}</span>
<span>${pass.maxEl.toFixed(0)}° max · ${pass.duration} min</span>
</div>
</div>
`;
}).join('');
}
function selectPass(idx) {
selectedPass = idx;
renderPassList();
const pass = passes[idx];
if (!pass) return;
drawPolarPlot(pass);
updateGroundTrack(pass);
updateTelemetry(pass);
// Immediately fetch full orbit track
updateRealTimePositions();
}
function drawPolarPlot(pass) {
const canvas = document.getElementById('polarPlot');
const ctx = canvas.getContext('2d');
const rect = canvas.parentElement.getBoundingClientRect();
canvas.width = rect.width;
canvas.height = rect.height - 20;
const cx = canvas.width / 2;
const cy = canvas.height / 2;
const radius = Math.min(cx, cy) - 40;
// Clear
ctx.fillStyle = '#0a0a0f';
ctx.fillRect(0, 0, canvas.width, canvas.height);
// Draw elevation rings
ctx.strokeStyle = 'rgba(0, 212, 255, 0.15)';
ctx.lineWidth = 1;
for (let el = 30; el <= 90; el += 30) {
const r = radius * (1 - el / 90);
ctx.beginPath();
ctx.arc(cx, cy, r, 0, Math.PI * 2);
ctx.stroke();
// Label
ctx.fillStyle = 'rgba(0, 212, 255, 0.4)';
ctx.font = '10px JetBrains Mono';
ctx.fillText(el + '°', cx + 5, cy - r + 12);
}
// Horizon
ctx.strokeStyle = 'rgba(0, 212, 255, 0.3)';
ctx.lineWidth = 2;
ctx.beginPath();
ctx.arc(cx, cy, radius, 0, Math.PI * 2);
ctx.stroke();
// Cardinal lines
ctx.strokeStyle = 'rgba(0, 212, 255, 0.1)';
ctx.lineWidth = 1;
for (let az = 0; az < 360; az += 45) {
const angle = (az - 90) * Math.PI / 180;
ctx.beginPath();
ctx.moveTo(cx, cy);
ctx.lineTo(cx + radius * Math.cos(angle), cy + radius * Math.sin(angle));
ctx.stroke();
}
// Cardinal labels
ctx.font = 'bold 14px Orbitron';
const labels = [
{ text: 'N', az: 0, color: '#ff4444' },
{ text: 'E', az: 90, color: '#00d4ff' },
{ text: 'S', az: 180, color: '#00d4ff' },
{ text: 'W', az: 270, color: '#00d4ff' }
];
labels.forEach(l => {
const angle = (l.az - 90) * Math.PI / 180;
const x = cx + (radius + 20) * Math.cos(angle);
const y = cy + (radius + 20) * Math.sin(angle);
ctx.fillStyle = l.color;
ctx.textAlign = 'center';
ctx.textBaseline = 'middle';
ctx.fillText(l.text, x, y);
});
// Draw pass trajectory
if (pass && pass.trajectory) {
ctx.strokeStyle = pass.color || '#00d4ff';
ctx.lineWidth = 3;
ctx.setLineDash([8, 4]);
ctx.beginPath();
let maxElPoint = null;
let maxEl = 0;
pass.trajectory.forEach((pt, i) => {
const r = radius * (1 - pt.el / 90);
const angle = (pt.az - 90) * Math.PI / 180;
const x = cx + r * Math.cos(angle);
const y = cy + r * Math.sin(angle);
if (i === 0) ctx.moveTo(x, y);
else ctx.lineTo(x, y);
if (pt.el > maxEl) {
maxEl = pt.el;
maxElPoint = { x, y };
}
});
ctx.stroke();
ctx.setLineDash([]);
// Max elevation marker
if (maxElPoint) {
ctx.beginPath();
ctx.arc(maxElPoint.x, maxElPoint.y, 8, 0, Math.PI * 2);
ctx.fillStyle = pass.color || '#00d4ff';
ctx.fill();
ctx.strokeStyle = '#fff';
ctx.lineWidth = 2;
ctx.stroke();
// Glow effect
ctx.beginPath();
ctx.arc(maxElPoint.x, maxElPoint.y, 15, 0, Math.PI * 2);
ctx.strokeStyle = pass.color || '#00d4ff';
ctx.lineWidth = 1;
ctx.globalAlpha = 0.3;
ctx.stroke();
ctx.globalAlpha = 1;
}
}
}
function updateGroundTrack(pass) {
if (!groundMap) return;
// Clear all existing map layers and reset references
if (trackLine) {
groundMap.removeLayer(trackLine);
trackLine = null;
}
if (satMarker) {
groundMap.removeLayer(satMarker);
satMarker = null;
}
if (orbitTrack) {
groundMap.removeLayer(orbitTrack);
orbitTrack = null;
}
if (pass && pass.groundTrack) {
// Split track at antimeridian crossings to avoid lines across map
const segments = [];
let currentSegment = [];
for (let i = 0; i < pass.groundTrack.length; i++) {
const p = pass.groundTrack[i];
if (currentSegment.length > 0) {
const prevLon = currentSegment[currentSegment.length - 1][1];
// If longitude jumps more than 180°, start new segment
if (Math.abs(p.lon - prevLon) > 180) {
if (currentSegment.length > 1) {
segments.push(currentSegment);
}
currentSegment = [];
}
}
currentSegment.push([p.lat, p.lon]);
}
if (currentSegment.length > 1) {
segments.push(currentSegment);
}
// Draw each segment as separate polyline
trackLine = L.layerGroup();
const allCoords = [];
segments.forEach(seg => {
L.polyline(seg, {
color: pass.color || '#00d4ff',
weight: 3,
opacity: 0.8,
dashArray: '10, 5'
}).addTo(trackLine);
allCoords.push(...seg);
});
trackLine.addTo(groundMap);
// Current position marker
if (pass.currentPos) {
const satIcon = L.divIcon({
className: 'sat-marker',
html: `<div style="
width: 16px; height: 16px;
background: ${pass.color || '#00d4ff'};
border-radius: 50%;
border: 2px solid #fff;
box-shadow: 0 0 20px ${pass.color || '#00d4ff'};
"></div>`,
iconSize: [16, 16],
iconAnchor: [8, 8]
});
satMarker = L.marker([pass.currentPos.lat, pass.currentPos.lon], { icon: satIcon })
.addTo(groundMap)
.bindPopup(`<b>${pass.name}</b><br>Alt: ${pass.currentPos.alt?.toFixed(0)} km`);
}
// Fit bounds using collected coordinates
if (allCoords.length > 0) {
groundMap.fitBounds(L.latLngBounds(allCoords), { padding: [30, 30] });
}
}
}
function updateObserverMarker(lat, lon) {
if (!groundMap) return;
if (observerMarker) groundMap.removeLayer(observerMarker);
const obsIcon = L.divIcon({
className: 'obs-marker',
html: `<div style="
width: 12px; height: 12px;
background: #ff9500;
border-radius: 50%;
border: 2px solid #fff;
box-shadow: 0 0 15px #ff9500;
"></div>`,
iconSize: [12, 12],
iconAnchor: [6, 6]
});
observerMarker = L.marker([lat, lon], { icon: obsIcon })
.addTo(groundMap)
.bindPopup('Observer Location');
}
function updateStats() {
document.getElementById('statTracked').textContent = Object.keys(satellites).length;
document.getElementById('statPasses').textContent = passes.length;
const maxEl = passes.reduce((max, p) => Math.max(max, p.maxEl || 0), 0);
document.getElementById('statMaxEl').textContent = maxEl.toFixed(0) + '°';
}
function updateTelemetry(pass) {
if (!pass || !pass.currentPos) {
document.getElementById('telLat').textContent = '---.----°';
document.getElementById('telLon').textContent = '---.----°';
document.getElementById('telAlt').textContent = '--- km';
document.getElementById('telEl').textContent = '--.-°';
document.getElementById('telAz').textContent = '---.-°';
document.getElementById('telDist').textContent = '---- km';
return;
}
const pos = pass.currentPos;
document.getElementById('telLat').textContent = (pos.lat || 0).toFixed(4) + '°';
document.getElementById('telLon').textContent = (pos.lon || 0).toFixed(4) + '°';
document.getElementById('telAlt').textContent = (pos.alt || 0).toFixed(0) + ' km';
document.getElementById('telEl').textContent = (pos.el || 0).toFixed(1) + '°';
document.getElementById('telAz').textContent = (pos.az || 0).toFixed(1) + '°';
document.getElementById('telDist').textContent = (pos.dist || 0).toFixed(0) + ' km';
}
function updateCountdown() {
if (!passes || passes.length === 0) {
document.getElementById('countdownSat').textContent = 'NO PASSES FOUND';
document.getElementById('countDays').textContent = '--';
document.getElementById('countHours').textContent = '--';
document.getElementById('countMins').textContent = '--';
document.getElementById('countSecs').textContent = '--';
return;
}
const now = new Date();
let nextPass = null;
for (const pass of passes) {
// Parse the startTimeISO field
const start = new Date(pass.startTimeISO);
if (start > now) {
nextPass = pass;
break;
}
}
if (!nextPass) {
// All passes are in the past, show the first one anyway
nextPass = passes[0];
}
document.getElementById('countdownSat').textContent = nextPass.satellite;
const passTime = new Date(nextPass.startTimeISO);
const diff = passTime - now;
if (diff <= 0) {
// Pass is happening now or passed
document.getElementById('countDays').textContent = '00';
document.getElementById('countHours').textContent = '00';
document.getElementById('countMins').textContent = '00';
document.getElementById('countSecs').textContent = '00';
return;
}
const days = Math.floor(diff / (1000 * 60 * 60 * 24));
const hours = Math.floor((diff % (1000 * 60 * 60 * 24)) / (1000 * 60 * 60));
const mins = Math.floor((diff % (1000 * 60 * 60)) / (1000 * 60));
const secs = Math.floor((diff % (1000 * 60)) / 1000);
document.getElementById('countDays').textContent = days.toString().padStart(2, '0');
document.getElementById('countHours').textContent = hours.toString().padStart(2, '0');
document.getElementById('countMins').textContent = mins.toString().padStart(2, '0');
document.getElementById('countSecs').textContent = secs.toString().padStart(2, '0');
// Animate if within 1 minute
const elements = ['countDays', 'countHours', 'countMins', 'countSecs'].map(id => document.getElementById(id));
if (diff < 60000) {
elements.forEach(el => el.classList.add('active'));
} else {
elements.forEach(el => el.classList.remove('active'));
}
}
async function updateRealTimePositions() {
const lat = parseFloat(document.getElementById('obsLat').value);
const lon = parseFloat(document.getElementById('obsLon').value);
// Use satellite from selected pass, or fall back to dropdown selection
let targetSatellite = selectedSatellite;
let satColor = satellites[selectedSatellite]?.color || '#00d4ff';
if (selectedPass !== null && passes[selectedPass]) {
const pass = passes[selectedPass];
// Use the satellite name from the pass (backend accepts names or NORAD IDs)
targetSatellite = pass.satellite;
satColor = pass.color || satColor;
}
try {
const response = await fetch('/satellite/position', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
latitude: lat,
longitude: lon,
satellites: [targetSatellite],
includeTrack: true
})
});
const data = await response.json();
if (data.status === 'success' && data.positions.length > 0) {
const pos = data.positions[0];
// Update telemetry
document.getElementById('telLat').textContent = pos.lat.toFixed(4) + '°';
document.getElementById('telLon').textContent = pos.lon.toFixed(4) + '°';
document.getElementById('telAlt').textContent = pos.altitude.toFixed(0) + ' km';
document.getElementById('telEl').textContent = pos.elevation.toFixed(1) + '°';
document.getElementById('telAz').textContent = pos.azimuth.toFixed(1) + '°';
document.getElementById('telDist').textContent = pos.distance.toFixed(0) + ' km';
// Update visible count
document.getElementById('statVisible').textContent = pos.elevation > 0 ? '1' : '0';
// Update satellite marker on map
if (groundMap) {
if (satMarker) groundMap.removeLayer(satMarker);
const satIcon = L.divIcon({
className: 'sat-marker-live',
html: `<div style="
width: 20px; height: 20px;
background: ${satColor};
border-radius: 50%;
border: 3px solid #fff;
box-shadow: 0 0 20px ${satColor}, 0 0 40px ${satColor};
"></div>`,
iconSize: [20, 20],
iconAnchor: [10, 10]
});
satMarker = L.marker([pos.lat, pos.lon], { icon: satIcon }).addTo(groundMap);
}
// Always show full orbit track from position data
if (pos.track && groundMap) {
if (orbitTrack) groundMap.removeLayer(orbitTrack);
if (trackLine) groundMap.removeLayer(trackLine);
trackLine = null;
// Split track at antimeridian crossings to avoid lines across map
const segments = [];
let currentSegment = [];
for (let i = 0; i < pos.track.length; i++) {
const p = pos.track[i];
if (currentSegment.length > 0) {
const prevLon = currentSegment[currentSegment.length - 1][1];
// If longitude jumps more than 180°, start new segment
if (Math.abs(p.lon - prevLon) > 180) {
if (currentSegment.length > 1) {
segments.push(currentSegment);
}
currentSegment = [];
}
}
currentSegment.push([p.lat, p.lon]);
}
if (currentSegment.length > 1) {
segments.push(currentSegment);
}
// Draw each segment as separate polyline
orbitTrack = L.layerGroup();
segments.forEach(seg => {
L.polyline(seg, {
color: satColor,
weight: 2,
opacity: 0.6,
dashArray: '5, 5'
}).addTo(orbitTrack);
});
orbitTrack.addTo(groundMap);
}
// Update polar plot - preserve pass trajectory if selected
if (selectedPass !== null && passes[selectedPass]) {
drawPolarPlot(passes[selectedPass]);
// Draw current position on top of pass trajectory
drawCurrentPositionOnPolar(pos.azimuth, pos.elevation, satColor);
} else {
drawPolarPlotWithPosition(pos.azimuth, pos.elevation, satColor);
}
}
} catch (err) {
console.error('Position update error:', err);
}
}
function drawPolarPlotWithPosition(az, el, color) {
const canvas = document.getElementById('polarPlot');
const ctx = canvas.getContext('2d');
const rect = canvas.parentElement.getBoundingClientRect();
canvas.width = rect.width;
canvas.height = rect.height - 20;
const cx = canvas.width / 2;
const cy = canvas.height / 2;
const radius = Math.min(cx, cy) - 40;
// Clear
ctx.fillStyle = '#0a0a0f';
ctx.fillRect(0, 0, canvas.width, canvas.height);
// Draw elevation rings
ctx.strokeStyle = 'rgba(0, 212, 255, 0.15)';
ctx.lineWidth = 1;
for (let elRing = 30; elRing <= 90; elRing += 30) {
const r = radius * (1 - elRing / 90);
ctx.beginPath();
ctx.arc(cx, cy, r, 0, Math.PI * 2);
ctx.stroke();
ctx.fillStyle = 'rgba(0, 212, 255, 0.4)';
ctx.font = '10px JetBrains Mono';
ctx.fillText(elRing + '°', cx + 5, cy - r + 12);
}
// Horizon
ctx.strokeStyle = 'rgba(0, 212, 255, 0.3)';
ctx.lineWidth = 2;
ctx.beginPath();
ctx.arc(cx, cy, radius, 0, Math.PI * 2);
ctx.stroke();
// Cardinal lines
ctx.strokeStyle = 'rgba(0, 212, 255, 0.1)';
ctx.lineWidth = 1;
for (let azLine = 0; azLine < 360; azLine += 45) {
const angle = (azLine - 90) * Math.PI / 180;
ctx.beginPath();
ctx.moveTo(cx, cy);
ctx.lineTo(cx + radius * Math.cos(angle), cy + radius * Math.sin(angle));
ctx.stroke();
}
// Cardinal labels
ctx.font = 'bold 14px Orbitron';
const labels = [
{ text: 'N', az: 0, color: '#ff4444' },
{ text: 'E', az: 90, color: '#00d4ff' },
{ text: 'S', az: 180, color: '#00d4ff' },
{ text: 'W', az: 270, color: '#00d4ff' }
];
labels.forEach(l => {
const angle = (l.az - 90) * Math.PI / 180;
const x = cx + (radius + 20) * Math.cos(angle);
const y = cy + (radius + 20) * Math.sin(angle);
ctx.fillStyle = l.color;
ctx.textAlign = 'center';
ctx.textBaseline = 'middle';
ctx.fillText(l.text, x, y);
});
// Draw pass trajectory only if the pass is currently happening
if (passes.length > 0 && selectedPass !== null && passes[selectedPass]?.trajectory) {
const pass = passes[selectedPass];
const passStart = new Date(pass.startTimeISO);
const passEnd = new Date(passStart.getTime() + (pass.duration || 10) * 60 * 1000);
const now = new Date();
// Only show trajectory if pass is currently active
if (now >= passStart && now <= passEnd) {
ctx.strokeStyle = color;
ctx.lineWidth = 2;
ctx.setLineDash([8, 4]);
ctx.globalAlpha = 0.5;
ctx.beginPath();
pass.trajectory.forEach((pt, i) => {
const r = radius * (1 - pt.el / 90);
const angle = (pt.az - 90) * Math.PI / 180;
const x = cx + r * Math.cos(angle);
const y = cy + r * Math.sin(angle);
if (i === 0) ctx.moveTo(x, y);
else ctx.lineTo(x, y);
});
ctx.stroke();
ctx.setLineDash([]);
ctx.globalAlpha = 1;
}
}
// Draw current satellite position
if (el > -5) { // Show even slightly below horizon
const posEl = Math.max(0, el);
const r = radius * (1 - posEl / 90);
const angle = (az - 90) * Math.PI / 180;
const x = cx + r * Math.cos(angle);
const y = cy + r * Math.sin(angle);
// Glow effect
const gradient = ctx.createRadialGradient(x, y, 0, x, y, 25);
gradient.addColorStop(0, color);
gradient.addColorStop(1, 'transparent');
ctx.fillStyle = gradient;
ctx.globalAlpha = 0.4;
ctx.beginPath();
ctx.arc(x, y, 25, 0, Math.PI * 2);
ctx.fill();
ctx.globalAlpha = 1;
// Main marker
ctx.beginPath();
ctx.arc(x, y, 10, 0, Math.PI * 2);
ctx.fillStyle = color;
ctx.fill();
ctx.strokeStyle = '#fff';
ctx.lineWidth = 3;
ctx.stroke();
// Satellite label
ctx.font = 'bold 11px Orbitron';
ctx.fillStyle = '#fff';
ctx.textAlign = 'center';
ctx.fillText(satellites[selectedSatellite]?.name || 'SAT', x, y - 20);
// Elevation indicator
ctx.font = '10px JetBrains Mono';
ctx.fillStyle = el > 0 ? '#00ff88' : '#ff4444';
ctx.fillText(el.toFixed(1) + '°', x, y + 25);
} else {
// Below horizon indicator
ctx.font = '12px Rajdhani';
ctx.fillStyle = '#ff4444';
ctx.textAlign = 'center';
ctx.fillText('BELOW HORIZON', cx, cy + radius + 35);
}
}
// Draw just the current position marker on the polar plot (without clearing)
function drawCurrentPositionOnPolar(az, el, color) {
const canvas = document.getElementById('polarPlot');
const ctx = canvas.getContext('2d');
const cx = canvas.width / 2;
const cy = canvas.height / 2;
const radius = Math.min(cx, cy) - 40;
// Draw current satellite position
if (el > -5) {
const posEl = Math.max(0, el);
const r = radius * (1 - posEl / 90);
const angle = (az - 90) * Math.PI / 180;
const x = cx + r * Math.cos(angle);
const y = cy + r * Math.sin(angle);
// Glow effect
const gradient = ctx.createRadialGradient(x, y, 0, x, y, 25);
gradient.addColorStop(0, color);
gradient.addColorStop(1, 'transparent');
ctx.fillStyle = gradient;
ctx.globalAlpha = 0.4;
ctx.beginPath();
ctx.arc(x, y, 25, 0, Math.PI * 2);
ctx.fill();
ctx.globalAlpha = 1;
// Main marker
ctx.beginPath();
ctx.arc(x, y, 10, 0, Math.PI * 2);
ctx.fillStyle = color;
ctx.fill();
ctx.strokeStyle = '#fff';
ctx.lineWidth = 3;
ctx.stroke();
// Satellite label
ctx.font = 'bold 11px Orbitron';
ctx.fillStyle = '#fff';
ctx.textAlign = 'center';
ctx.fillText(satellites[selectedSatellite]?.name || 'SAT', x, y - 20);
// Elevation indicator
ctx.font = '10px JetBrains Mono';
ctx.fillStyle = el > 0 ? '#00ff88' : '#ff4444';
ctx.fillText(el.toFixed(1) + '°', x, y + 25);
}
}
</script>
</body>
</html>