mirror of
https://github.com/smittix/intercept.git
synced 2026-04-25 07:10:00 -07:00
Split pass ground track at 180° longitude crossings to prevent lines being drawn across the entire map.
1638 lines
60 KiB
HTML
1638 lines
60 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">0°</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);
|
|
}
|
|
|
|
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
|
|
if (trackLine) groundMap.removeLayer(trackLine);
|
|
if (satMarker) groundMap.removeLayer(satMarker);
|
|
if (orbitTrack) groundMap.removeLayer(orbitTrack);
|
|
|
|
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();
|
|
segments.forEach(seg => {
|
|
L.polyline(seg, {
|
|
color: pass.color || '#00d4ff',
|
|
weight: 3,
|
|
opacity: 0.8,
|
|
dashArray: '10, 5'
|
|
}).addTo(trackLine);
|
|
});
|
|
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`);
|
|
}
|
|
|
|
groundMap.fitBounds(trackLine.getBounds(), { 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);
|
|
const satColor = satellites[selectedSatellite]?.color || '#00d4ff';
|
|
|
|
try {
|
|
const response = await fetch('/satellite/position', {
|
|
method: 'POST',
|
|
headers: { 'Content-Type': 'application/json' },
|
|
body: JSON.stringify({
|
|
latitude: lat,
|
|
longitude: lon,
|
|
satellites: [selectedSatellite],
|
|
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);
|
|
}
|
|
|
|
// Only update orbit track if no pass is selected
|
|
// When a pass is selected, keep showing that pass's ground track
|
|
if (selectedPass === null && 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>
|