Files
intercept/templates/satellite_dashboard.html
2026-03-18 22:53:36 +00:00

2297 lines
105 KiB
HTML
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<!DOCTYPE html>
<html lang="en" class="{% if offline_settings.tile_provider in ['cartodb_dark', 'cartodb_dark_cyan'] %}map-cyber-enabled{% endif %}">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>SATELLITE COMMAND // iNTERCEPT - See the Invisible</title>
<!-- Dedicated dashboards always use bundled assets so navigation is not
blocked by external CDN reachability. -->
<link rel="stylesheet" href="{{ url_for('static', filename='css/fonts-local.css') }}">
<link rel="stylesheet" href="{{ url_for('static', filename='vendor/leaflet/leaflet.css') }}">
<script src="{{ url_for('static', filename='vendor/leaflet/leaflet.js') }}"></script>
<!-- Core CSS variables -->
<link rel="stylesheet" href="{{ url_for('static', filename='css/core/variables.css') }}">
<link rel="stylesheet" href="{{ url_for('static', filename='css/responsive.css') }}">
<link rel="stylesheet" href="{{ url_for('static', filename='css/core/layout.css') }}">
<link rel="stylesheet" href="{{ url_for('static', filename='css/settings.css') }}?v={{ version }}&r=maptheme17">
<link rel="stylesheet" href="{{ url_for('static', filename='css/help-modal.css') }}">
<link rel="stylesheet" href="{{ url_for('static', filename='css/satellite_dashboard.css') }}">
<script>
window.INTERCEPT_SHARED_OBSERVER_LOCATION = {{ shared_observer_location | tojson }};
</script>
<script src="{{ url_for('static', filename='js/core/observer-location.js') }}"></script>
</head>
<body>
<div class="grid-bg"></div>
<div class="scanline"></div>
<header class="header">
<div class="logo">
SATELLITE COMMAND
<span>// <span class="brand-i"><svg viewBox="36 14 28 68" width="1em" height="1em" xmlns="http://www.w3.org/2000/svg"><circle cx="50" cy="20" r="6" fill="#00ff88"/><rect x="44" y="33" width="12" height="45" rx="2" fill="#00d4ff"/><rect x="38" y="33" width="24" height="4" rx="1" fill="#00d4ff"/><rect x="38" y="74" width="24" height="4" rx="1" fill="#00d4ff"/></svg></span>NTERCEPT - See the Invisible</span>
</div>
<div class="stats-badges">
<div class="stat-badge">
<span class="value" id="statTracked">7</span>
<span class="label">satellites</span>
</div>
<div class="stat-badge">
<span class="value highlight" id="statVisible">0</span>
<span class="label">visible</span>
</div>
<div class="stat-badge">
<span class="value" id="statPasses">0</span>
<span class="label">passes</span>
</div>
<div class="stat-badge">
<span class="value" id="statMaxEl">0</span>
<span class="label">best el</span>
</div>
</div>
<div class="status-bar">
<!-- Location Source Selector -->
<div class="location-selector" id="locationSection">
<span class="location-label">Location:</span>
<select id="locationSource" class="location-select" title="Select observer location">
<option value="local">Local (This Device)</option>
</select>
<span class="location-status-dot online" id="locationStatusDot"></span>
</div>
<div class="status-item">
<div class="status-dot" id="trackingDot"></div>
<span id="trackingStatus">TRACKING</span>
</div>
<div class="datetime" id="utcTime">--:--:-- UTC</div>
</div>
</header>
{% if not embedded %}
{% set active_mode = 'satellite' %}
{% include 'partials/nav.html' with context %}
{% endif %}
<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" style="padding: 0;">
<div id="groundMap"></div>
</div>
</div>
<!-- Sidebar -->
<div class="sidebar">
<!-- Satellite Selector -->
<div class="satellite-selector">
<label>TARGET:</label>
<select id="satSelect" onchange="onSatelliteChange()">
<option value="25544">ISS (ZARYA)</option>
<option value="57166">METEOR-M2-3</option>
<option value="59051">METEOR-M2-4</option>
</select>
<button id="satRefreshBtn" onclick="loadDashboardSatellites()" title="Refresh satellite list"></button>
</div>
<!-- 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>
<!-- 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-item">
<div class="telemetry-label">Latitude</div>
<div class="telemetry-value" id="telLat">---.----</div>
</div>
<div class="telemetry-item">
<div class="telemetry-label">Longitude</div>
<div class="telemetry-value" id="telLon">---.----</div>
</div>
<div class="telemetry-item">
<div class="telemetry-label">Altitude</div>
<div class="telemetry-value" id="telAlt">--- km</div>
</div>
<div class="telemetry-item">
<div class="telemetry-label">Elevation</div>
<div class="telemetry-value" id="telEl">--.-</div>
</div>
<div class="telemetry-item">
<div class="telemetry-label">Azimuth</div>
<div class="telemetry-value" id="telAz">---.-</div>
</div>
<div class="telemetry-item">
<div class="telemetry-label">Distance</div>
<div class="telemetry-value" id="telDist">---- km</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>
<!-- Transmitters -->
<div class="panel transmitters-panel">
<div class="panel-header">
<span>TRANSMITTERS <span id="txCount" style="color:var(--accent-cyan);"></span></span>
<div class="panel-indicator"></div>
</div>
<div class="panel-content" id="transmittersList">
<div style="text-align:center;color:var(--text-secondary);padding:15px;font-size:11px;">
Select a satellite to load transmitters
</div>
</div>
</div>
<!-- Ground Station -->
<div class="panel gs-panel" id="gsPanel">
<div class="panel-header">
<span>GROUND STATION</span>
<div class="panel-indicator" id="gsIndicator"></div>
</div>
<div class="panel-content">
<!-- Scheduler status -->
<div class="gs-status-row">
<span style="color:var(--text-secondary);font-size:11px;">Scheduler</span>
<span id="gsSchedulerStatus" style="color:var(--text-secondary);font-size:11px;">IDLE</span>
</div>
<div class="gs-status-row" id="gsActiveRow" style="display:none;">
<span style="color:var(--text-secondary);font-size:11px;">Capturing</span>
<span id="gsActiveSat" style="color:var(--accent-cyan);font-family:var(--font-mono);font-size:11px;">-</span>
</div>
<div class="gs-status-row" id="gsDopplerRow" style="display:none;">
<span style="color:var(--text-secondary);font-size:11px;">Doppler</span>
<span id="gsDopplerShift" style="color:var(--accent-green);font-family:var(--font-mono);font-size:11px;">+0 Hz</span>
</div>
<!-- Quick enable -->
<div style="margin-top:8px;display:flex;gap:6px;">
<button class="pass-capture-btn" onclick="gsEnableScheduler()" id="gsEnableBtn">ENABLE</button>
<button class="pass-capture-btn" onclick="gsDisableScheduler()" id="gsDisableBtn" style="display:none;border-color:rgba(255,80,80,0.5);color:#ff6666;">DISABLE</button>
<button class="pass-capture-btn" onclick="gsStopActive()" id="gsStopBtn" style="display:none;">STOP</button>
</div>
<!-- Observation profiles -->
<div style="margin-top:10px;">
<div style="display:flex;justify-content:space-between;align-items:center;margin-bottom:6px;">
<span style="font-size:10px;color:var(--text-secondary);letter-spacing:0.05em;">OBSERVATION PROFILES</span>
<button class="pass-capture-btn" onclick="gsShowProfileForm()" id="gsAddProfileBtn" style="font-size:9px;padding:2px 7px;">+ ADD</button>
</div>
<div id="gsProfileList" style="max-height:140px;overflow-y:auto;"></div>
<!-- Inline profile form (hidden by default) -->
<div id="gsProfileForm" style="display:none;background:rgba(0,40,60,0.6);border:1px solid rgba(0,212,255,0.2);border-radius:4px;padding:8px;margin-top:6px;">
<div style="font-size:10px;color:var(--accent-cyan);margin-bottom:6px;" id="gsProfileFormTitle">NEW PROFILE</div>
<input type="hidden" id="gsProfNorad">
<div class="gs-form-row">
<label class="gs-form-label">Satellite</label>
<span id="gsProfSatName" style="font-size:11px;color:var(--text-primary);font-family:var(--font-mono);">-</span>
</div>
<div class="gs-form-row">
<label class="gs-form-label">Frequency</label>
<div style="display:flex;align-items:center;gap:4px;">
<input type="number" id="gsProfFreq" step="0.001" min="50" max="2000" style="width:80px;" placeholder="MHz">
<span style="font-size:10px;color:var(--text-secondary);">MHz</span>
</div>
</div>
<div class="gs-form-row">
<label class="gs-form-label">Decoder</label>
<select id="gsProfDecoder" style="font-size:11px;background:rgba(0,20,40,0.8);border:1px solid rgba(0,212,255,0.3);color:var(--text-primary);border-radius:3px;padding:2px 4px;">
<option value="fm">FM (general)</option>
<option value="afsk">AFSK / AX.25</option>
<option value="gmsk">GMSK</option>
<option value="bpsk">BPSK (gr-satellites)</option>
<option value="iq_only">IQ record only</option>
</select>
</div>
<div style="margin-top:8px;padding-top:6px;border-top:1px solid rgba(0,212,255,0.08);">
<div style="font-size:10px;color:var(--text-secondary);margin-bottom:6px;letter-spacing:0.05em;">TASKS</div>
<label style="display:flex;align-items:center;gap:6px;font-size:10px;color:var(--text-primary);margin-bottom:4px;">
<input type="checkbox" id="gsTaskTelemetryAx25" style="accent-color:var(--accent-cyan);">
Telemetry AX.25 / AFSK
</label>
<label style="display:flex;align-items:center;gap:6px;font-size:10px;color:var(--text-primary);margin-bottom:4px;">
<input type="checkbox" id="gsTaskTelemetryGmsk" style="accent-color:var(--accent-cyan);">
Telemetry GMSK
</label>
<label style="display:flex;align-items:center;gap:6px;font-size:10px;color:var(--text-primary);margin-bottom:4px;">
<input type="checkbox" id="gsTaskTelemetryBpsk" style="accent-color:var(--accent-cyan);">
Telemetry BPSK
</label>
<label style="display:flex;align-items:center;gap:6px;font-size:10px;color:var(--text-primary);margin-bottom:4px;">
<input type="checkbox" id="gsTaskWeatherMeteor" style="accent-color:var(--accent-cyan);">
Meteor LRPT capture
</label>
<label style="display:flex;align-items:center;gap:6px;font-size:10px;color:var(--text-primary);">
<input type="checkbox" id="gsTaskRecordIq" style="accent-color:var(--accent-cyan);">
Record IQ artifact
</label>
</div>
<div class="gs-form-row">
<label class="gs-form-label">Min El</label>
<div style="display:flex;align-items:center;gap:4px;">
<input type="number" id="gsProfMinEl" value="10" min="0" max="90" style="width:50px;">
<span style="font-size:10px;color:var(--text-secondary);">°</span>
</div>
</div>
<div class="gs-form-row">
<label class="gs-form-label">Gain</label>
<input type="number" id="gsProfGain" value="40" min="0" max="60" style="width:50px;">
</div>
<div class="gs-form-row">
<label class="gs-form-label">Record IQ</label>
<input type="checkbox" id="gsProfRecordIQ" style="accent-color:var(--accent-cyan);">
</div>
<div style="display:flex;gap:6px;margin-top:8px;">
<button class="pass-capture-btn" onclick="gsSaveProfile()" style="flex:1;">SAVE</button>
<button class="pass-capture-btn" onclick="gsHideProfileForm()" style="flex:1;border-color:rgba(255,80,80,0.4);color:#ff6666;">CANCEL</button>
</div>
<div id="gsProfileError" style="display:none;color:#ff6666;font-size:10px;margin-top:4px;"></div>
</div>
</div>
<!-- Upcoming auto-observations -->
<div style="margin-top:10px;">
<div style="font-size:10px;color:var(--text-secondary);margin-bottom:4px;letter-spacing:0.05em;">UPCOMING PASSES</div>
<div id="gsUpcomingList" style="max-height:120px;overflow-y:auto;"></div>
</div>
<!-- Live waterfall (Phase 5) -->
<div id="gsWaterfallPanel" style="display:none;margin-top:8px;">
<div style="font-size:10px;color:var(--text-secondary);margin-bottom:4px;">
LIVE SPECTRUM <span id="gsWaterfallStatus" style="color:var(--accent-cyan);">STOPPED</span>
</div>
<canvas id="gs-waterfall" style="width:100%;height:160px;background:#000a14;display:block;"></canvas>
</div>
<!-- SigMF recordings -->
<div id="gsRecordingsPanel" style="margin-top:8px;display:none;">
<div style="font-size:10px;color:var(--text-secondary);margin-bottom:4px;">IQ RECORDINGS</div>
<div id="gsRecordingsList" style="max-height:100px;overflow-y:auto;"></div>
</div>
<div id="gsOutputsPanel" style="margin-top:8px;display:none;">
<div style="font-size:10px;color:var(--text-secondary);margin-bottom:4px;">DECODED IMAGERY</div>
<div id="gsDecodeStatus" style="display:none;color:var(--accent-cyan);font-size:9px;font-family:var(--font-mono);margin-bottom:4px;"></div>
<div id="gsOutputsList" style="max-height:120px;overflow-y:auto;"></div>
</div>
<!-- Rotator (Phase 6, shown only if connected) -->
<div id="gsRotatorPanel" style="display:none;margin-top:8px;">
<div style="font-size:10px;color:var(--text-secondary);margin-bottom:4px;">ROTATOR</div>
<div class="gs-status-row">
<span style="font-size:10px;color:var(--text-secondary);">AZ/EL</span>
<span id="gsRotatorPos" style="font-family:var(--font-mono);font-size:10px;color:var(--accent-cyan);">---/--</span>
</div>
</div>
</div>
</div>
<!-- Decoded Packets -->
<div class="panel packets-panel">
<div class="panel-header">
<span>DECODED PACKETS <span id="packetCount" style="color:var(--accent-cyan);"></span></span>
<div class="panel-indicator"></div>
</div>
<div class="panel-content" id="packetList">
<div style="text-align:center;color:var(--text-secondary);padding:15px;font-size:11px;">
No packets received yet.<br>Run a ground-station observation with telemetry tasks enabled to populate this panel.
</div>
</div>
</div>
</div>
<!-- Controls Bar -->
<div class="controls-bar">
<div class="control-group">
<span class="control-label">Lat:</span>
<input type="number" id="obsLat" value="51.5074" step="0.0001">
</div>
<div class="control-group">
<span class="control-label">Lon:</span>
<input type="number" id="obsLon" value="-0.1278" step="0.0001">
</div>
<button class="btn" onclick="getLocation()">GPS</button>
<button class="btn primary" onclick="calculatePasses()">CALCULATE</button>
</div>
</main>
<style>
/* Location selector styles */
.location-selector {
display: flex;
align-items: center;
gap: 6px;
margin-right: 15px;
}
.location-label {
font-size: 11px;
color: var(--text-secondary, #8899aa);
font-family: var(--font-mono);
}
.location-select {
background: rgba(0, 40, 60, 0.8);
border: 1px solid rgba(0, 200, 255, 0.3);
color: #e0f7ff;
padding: 4px 8px;
border-radius: 4px;
font-size: 11px;
font-family: var(--font-mono);
cursor: pointer;
min-width: 140px;
}
.location-select:focus {
outline: none;
border-color: #00d4ff;
}
.location-status-dot {
width: 8px;
height: 8px;
border-radius: 50%;
flex-shrink: 0;
}
.location-status-dot.online {
background: #00ff88;
box-shadow: 0 0 6px #00ff88;
}
.location-status-dot.offline {
background: #ff4444;
box-shadow: 0 0 6px #ff4444;
}
/* Pass event row */
.pass-event-row {
display: flex;
justify-content: space-between;
align-items: center;
font-size: 10px;
color: var(--accent-cyan);
opacity: 0.75;
margin-top: 4px;
font-family: var(--font-mono);
}
.pass-capture-btn {
background: rgba(0, 255, 136, 0.12);
border: 1px solid rgba(0, 255, 136, 0.4);
color: var(--accent-green, #00ff88);
font-size: 10px;
font-family: var(--font-mono);
padding: 2px 7px;
border-radius: 3px;
cursor: pointer;
white-space: nowrap;
transition: background 0.15s;
}
.pass-capture-btn:hover {
background: rgba(0, 255, 136, 0.25);
}
/* Transmitters panel */
.transmitters-panel, .packets-panel {
margin-top: 10px;
}
.tx-item {
display: flex;
align-items: flex-start;
gap: 8px;
padding: 8px;
border-bottom: 1px solid rgba(0,212,255,0.08);
font-size: 11px;
}
.tx-item:last-child { border-bottom: none; }
.tx-inactive { opacity: 0.5; }
.tx-status-dot {
width: 8px;
height: 8px;
border-radius: 50%;
flex-shrink: 0;
margin-top: 3px;
}
.tx-body { flex: 1; min-width: 0; }
.tx-desc {
color: var(--text-primary);
font-weight: 500;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.tx-freq {
color: var(--accent-cyan);
font-family: var(--font-mono);
font-size: 10px;
margin-top: 2px;
}
.tx-uplink { color: var(--accent-green, #00ff88); }
.tx-service {
color: var(--text-muted, #556677);
font-size: 10px;
margin-top: 1px;
}
/* Ground Station panel */
.gs-panel { margin-top: 10px; }
.gs-status-row {
display: flex;
justify-content: space-between;
align-items: center;
padding: 3px 0;
border-bottom: 1px solid rgba(0,212,255,0.06);
font-size: 11px;
}
.gs-obs-item {
display: flex;
justify-content: space-between;
align-items: center;
padding: 4px 0;
font-size: 10px;
border-bottom: 1px solid rgba(0,212,255,0.06);
font-family: var(--font-mono);
}
.gs-obs-item .sat-name { color: var(--text-primary); }
.gs-obs-item .obs-time { color: var(--text-secondary); font-size: 9px; }
.gs-recording-item {
display: flex;
justify-content: space-between;
align-items: center;
padding: 3px 0;
font-size: 10px;
border-bottom: 1px solid rgba(0,212,255,0.06);
}
.gs-recording-item a {
color: var(--accent-cyan);
text-decoration: none;
font-family: var(--font-mono);
}
.gs-recording-item a:hover { text-decoration: underline; }
.gs-form-row {
display: flex;
justify-content: space-between;
align-items: center;
padding: 3px 0;
border-bottom: 1px solid rgba(0,212,255,0.06);
}
.gs-form-label {
font-size: 10px;
color: var(--text-secondary);
min-width: 58px;
}
.gs-form-row input[type="number"],
.gs-form-row select {
background: rgba(0,20,40,0.8);
border: 1px solid rgba(0,212,255,0.3);
color: var(--text-primary);
border-radius: 3px;
padding: 2px 4px;
font-family: var(--font-mono);
font-size: 11px;
}
.gs-profile-item {
display: flex;
align-items: center;
justify-content: space-between;
padding: 5px 0;
border-bottom: 1px solid rgba(0,212,255,0.06);
font-size: 10px;
}
.gs-profile-item .prof-name {
color: var(--text-primary);
font-family: var(--font-mono);
font-size: 10px;
flex: 1;
min-width: 0;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.gs-profile-item .prof-freq {
color: var(--accent-cyan);
font-family: var(--font-mono);
font-size: 9px;
margin: 0 6px;
flex-shrink: 0;
}
.gs-profile-item .prof-actions {
display: flex;
gap: 4px;
flex-shrink: 0;
}
.gs-profile-item button {
background: none;
border: 1px solid rgba(0,212,255,0.3);
color: var(--accent-cyan);
border-radius: 3px;
padding: 1px 5px;
font-size: 9px;
cursor: pointer;
font-family: var(--font-mono);
}
.gs-profile-item button:hover { background: rgba(0,212,255,0.1); }
.gs-profile-item button.del { border-color: rgba(255,80,80,0.4); color: #ff6666; }
.gs-profile-item button.del:hover { background: rgba(255,80,80,0.1); }
.gs-profile-enabled { color: var(--accent-green) !important; }
</style>
<script>
// Check if embedded mode
const urlParams = new URLSearchParams(window.location.search);
const isEmbedded = urlParams.get('embedded') === 'true';
// 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;
let currentLocationSource = 'local';
let agents = [];
let _txRequestId = 0;
let _telemetryPollTimer = null;
let _passRequestId = 0;
let satellites = {
25544: { name: 'ISS (ZARYA)', color: '#00ffff' },
57166: { name: 'METEOR-M2-3', color: '#ff00ff' },
59051: { name: 'METEOR-M2-4', color: '#00ff88' }
};
const satColors = ['#00ffff', '#9370DB', '#ff00ff', '#00ff00', '#ff6600', '#ffff00', '#ff69b4', '#7b68ee'];
function loadDashboardSatellites() {
const btn = document.getElementById('satRefreshBtn');
if (btn) {
btn.classList.remove('spinning');
void btn.offsetWidth; // force reflow to restart animation
btn.classList.add('spinning');
}
fetch('/satellite/tracked?enabled=true')
.then(r => r.json())
.then(data => {
const prevSelected = selectedSatellite;
const newSats = {
25544: { name: 'ISS (ZARYA)', color: satellites[25544]?.color || satColors[0] },
57166: { name: 'METEOR-M2-3', color: satellites[57166]?.color || satColors[2] },
59051: { name: 'METEOR-M2-4', color: satellites[59051]?.color || satColors[4] },
};
const select = document.getElementById('satSelect');
if (!select) return;
if (data.status === 'success' && Array.isArray(data.satellites)) {
data.satellites.forEach((sat, i) => {
const norad = parseInt(sat.norad_id);
if (!Number.isFinite(norad)) return;
newSats[norad] = {
name: sat.name,
color: satellites[norad]?.color || satColors[i % satColors.length]
};
});
}
satellites = newSats;
select.innerHTML = '';
Object.entries(newSats).forEach(([norad, sat]) => {
const opt = document.createElement('option');
opt.value = norad;
opt.textContent = sat.name;
select.appendChild(opt);
});
if (newSats[prevSelected]) {
select.value = String(prevSelected);
} else if (newSats[25544]) {
select.value = '25544';
}
selectedSatellite = parseInt(select.value);
clearTelemetry();
loadTransmitters(selectedSatellite);
calculatePasses();
fetchCurrentTelemetry();
if (window.gsLoadOutputs) window.gsLoadOutputs();
if (window.gsOnSatelliteChange) window.gsOnSatelliteChange();
})
.catch(() => {})
.finally(() => {
if (btn) btn.classList.remove('spinning');
});
}
function onSatelliteChange() {
const select = document.getElementById('satSelect');
selectedSatellite = parseInt(select.value);
const satName = satellites[selectedSatellite]?.name || 'Unknown';
document.getElementById('trackingStatus').textContent = 'ACQUIRING';
document.getElementById('trackingDot').style.background = 'var(--accent-orange)';
selectedPass = null;
passes = [];
if (groundMap) {
if (trackLine) { groundMap.removeLayer(trackLine); trackLine = null; }
if (satMarker) { groundMap.removeLayer(satMarker); satMarker = null; }
if (orbitTrack) { groundMap.removeLayer(orbitTrack); orbitTrack = null; }
}
clearTelemetry();
loadTransmitters(selectedSatellite);
calculatePasses();
fetchCurrentTelemetry();
if (window.gsLoadOutputs) window.gsLoadOutputs();
if (window.gsOnSatelliteChange) gsOnSatelliteChange();
}
function setupEmbeddedMode() {
if (isEmbedded) {
// Hide back link when embedded
const backLink = document.querySelector('.back-link');
if (backLink) backLink.style.display = 'none';
// Add embedded class to body for CSS adjustments
document.body.classList.add('embedded');
// Compact the header slightly
const header = document.querySelector('.header');
if (header) header.style.padding = '10px 20px';
// Hide decorative elements
const gridBg = document.querySelector('.grid-bg');
const scanline = document.querySelector('.scanline');
if (gridBg) gridBg.style.display = 'none';
if (scanline) scanline.style.display = 'none';
}
}
function applySharedObserverLocation() {
if (window.ObserverLocation && ObserverLocation.isSharedEnabled()) {
const shared = ObserverLocation.getShared();
if (shared) {
const latInput = document.getElementById('obsLat');
const lonInput = document.getElementById('obsLon');
if (latInput) latInput.value = shared.lat.toFixed(4);
if (lonInput) lonInput.value = shared.lon.toFixed(4);
return true;
}
}
return false;
}
let satelliteSSE = null;
function startSSETracking() {
if (satelliteSSE) return;
satelliteSSE = new EventSource('/satellite/stream_satellite');
satelliteSSE.onmessage = (e) => {
try {
const msg = JSON.parse(e.data);
if (msg.type === 'positions') handleLivePositions(msg.positions);
} catch (_) {}
};
satelliteSSE.onerror = () => {
// Reconnect automatically after 5s
if (satelliteSSE) { satelliteSSE.close(); satelliteSSE = null; }
setTimeout(startSSETracking, 5000);
};
}
function stopSSETracking() {
if (satelliteSSE) { satelliteSSE.close(); satelliteSSE = null; }
}
function handleLivePositions(positions) {
// Find the selected satellite by name or norad_id
const satName = satellites[selectedSatellite]?.name;
const pos = positions.find(p =>
parseInt(p.norad_id) === selectedSatellite ||
p.satellite === satName ||
p.satellite === satellites[selectedSatellite]?.name
);
// Update visible count from all positions
const visibleCount = positions.filter(p => p.visible).length;
const visEl = document.getElementById('statVisible');
if (visEl) visEl.textContent = visibleCount;
if (!pos) {
clearTelemetry();
return;
}
// Update telemetry panel
const telLat = document.getElementById('telLat');
const telLon = document.getElementById('telLon');
const telAlt = document.getElementById('telAlt');
const telEl = document.getElementById('telEl');
const telAz = document.getElementById('telAz');
const telDist = document.getElementById('telDist');
if (telLat) telLat.textContent = (pos.lat ?? 0).toFixed(4) + '°';
if (telLon) telLon.textContent = (pos.lon ?? 0).toFixed(4) + '°';
if (telAlt) telAlt.textContent = (pos.altitude ?? 0).toFixed(0) + ' km';
if (telEl) telEl.textContent = (pos.elevation ?? 0).toFixed(1) + '°';
if (telAz) telAz.textContent = (pos.azimuth ?? 0).toFixed(1) + '°';
if (telDist) telDist.textContent = (pos.distance ?? 0).toFixed(0) + ' km';
// Update live marker on map
if (groundMap && pos.lat != null && pos.lon != null) {
const satColor = satellites[selectedSatellite]?.color || '#00d4ff';
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);
}
// Update orbit track from groundTrack if available
if (groundMap && pos.groundTrack && pos.groundTrack.length > 1) {
if (orbitTrack) { groundMap.removeLayer(orbitTrack); orbitTrack = null; }
const satColor = satellites[selectedSatellite]?.color || '#00d4ff';
const segments = splitAtAntimeridian(pos.groundTrack);
orbitTrack = L.layerGroup();
segments.forEach(seg => {
const past = seg.filter(p => p.past);
const future = seg.filter(p => !p.past);
if (past.length > 1) L.polyline(past.map(p => [p.lat, p.lon]), { color: satColor, weight: 2, opacity: 0.4 }).addTo(orbitTrack);
if (future.length > 1) L.polyline(future.map(p => [p.lat, p.lon]), { color: satColor, weight: 2, opacity: 0.7, dashArray: '5, 5' }).addTo(orbitTrack);
});
orbitTrack.addTo(groundMap);
}
}
function clearTelemetry() {
const telLat = document.getElementById('telLat');
const telLon = document.getElementById('telLon');
const telAlt = document.getElementById('telAlt');
const telEl = document.getElementById('telEl');
const telAz = document.getElementById('telAz');
const telDist = document.getElementById('telDist');
if (telLat) telLat.textContent = '---.----';
if (telLon) telLon.textContent = '---.----';
if (telAlt) telAlt.textContent = '--- km';
if (telEl) telEl.textContent = '--.-';
if (telAz) telAz.textContent = '---.-';
if (telDist) telDist.textContent = '---- km';
}
async function fetchCurrentTelemetry() {
const lat = parseFloat(document.getElementById('obsLat')?.value);
const lon = parseFloat(document.getElementById('obsLon')?.value);
if (!Number.isFinite(lat) || !Number.isFinite(lon) || !selectedSatellite) return;
try {
const response = await fetch('/satellite/position', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
latitude: lat,
longitude: lon,
satellites: [selectedSatellite],
includeTrack: false
})
});
if (!response.ok) return;
const data = await response.json();
if (data.status !== 'success' || !Array.isArray(data.positions)) return;
handleLivePositions(data.positions);
} catch (_) {}
}
function startTelemetryPolling() {
if (_telemetryPollTimer) return;
_telemetryPollTimer = setInterval(fetchCurrentTelemetry, 10000);
}
function splitAtAntimeridian(track) {
const segments = [];
let current = [];
for (let i = 0; i < track.length; i++) {
const p = track[i];
if (current.length > 0) {
const prev = current[current.length - 1];
if ((prev.lon > 90 && p.lon < -90) || (prev.lon < -90 && p.lon > 90)) {
if (current.length >= 2) segments.push(current);
current = [];
}
}
current.push(p);
}
if (current.length >= 2) segments.push(current);
return segments;
}
// Listen for visibility messages from parent page (embedded mode)
window.addEventListener('message', (event) => {
if (event.data && event.data.type === 'satellite-visibility') {
if (event.data.visible) {
startSSETracking();
} else {
stopSSETracking();
}
}
});
// Refresh satellite list when the window regains focus (e.g. after enabling sats in the sidebar)
window.addEventListener('focus', loadDashboardSatellites);
document.addEventListener('DOMContentLoaded', () => {
loadDashboardSatellites();
setupEmbeddedMode();
const usedShared = applySharedObserverLocation();
initGroundMap();
updateClock();
setInterval(updateClock, 1000);
setInterval(updateCountdown, 1000);
// In standalone mode, start SSE tracking immediately.
// In embedded mode, wait for parent to signal visibility.
if (!isEmbedded) {
startSSETracking();
}
startTelemetryPolling();
loadAgents();
loadTransmitters(selectedSatellite);
fetchCurrentTelemetry();
if (!usedShared) {
getLocation();
}
});
async function loadAgents() {
try {
const response = await fetch('/controller/agents');
const data = await response.json();
if (data.status === 'success' && data.agents) {
agents = data.agents;
populateLocationSelector();
}
} catch (err) {
console.log('No agents available (controller not running)');
}
}
function populateLocationSelector() {
const select = document.getElementById('locationSource');
if (!select) return;
// Keep local option, add agents with GPS
agents.forEach(agent => {
const option = document.createElement('option');
option.value = 'agent-' + agent.id;
option.textContent = agent.name;
if (agent.gps_coords) {
option.textContent += ' (GPS)';
}
select.appendChild(option);
});
select.addEventListener('change', onLocationSourceChange);
}
async function onLocationSourceChange() {
const select = document.getElementById('locationSource');
const value = select.value;
currentLocationSource = value;
const statusDot = document.getElementById('locationStatusDot');
if (value === 'local') {
// Use local GPS
statusDot.className = 'location-status-dot online';
getLocation();
} else if (value.startsWith('agent-')) {
// Fetch agent's GPS position
const agentId = value.replace('agent-', '');
try {
statusDot.className = 'location-status-dot online';
const response = await fetch(`/controller/agents/${agentId}/status`);
const data = await response.json();
if (data.status === 'success' && data.result) {
const agentStatus = data.result;
if (agentStatus.gps_position) {
const gps = agentStatus.gps_position;
document.getElementById('obsLat').value = gps.lat.toFixed(4);
document.getElementById('obsLon').value = gps.lon.toFixed(4);
if (window.ObserverLocation && ObserverLocation.isSharedEnabled()) {
ObserverLocation.setShared({ lat: gps.lat, lon: gps.lon });
}
// Update observer marker label
const agent = agents.find(a => a.id == agentId);
if (agent) {
console.log(`Using GPS from agent: ${agent.name} (${gps.lat.toFixed(4)}, ${gps.lon.toFixed(4)})`);
}
calculatePasses();
} else {
alert('Agent does not have GPS data available');
statusDot.className = 'location-status-dot offline';
}
}
} catch (err) {
console.error('Failed to get agent GPS:', err);
statusDot.className = 'location-status-dot offline';
alert('Failed to connect to agent');
}
}
}
function updateClock() {
const now = new Date();
document.getElementById('utcTime').textContent =
now.toISOString().substring(11, 19) + ' UTC';
}
function createFallbackGridLayer() {
const layer = L.gridLayer({
tileSize: 256,
updateWhenIdle: true,
attribution: 'Local fallback grid'
});
layer.createTile = function(coords) {
const tile = document.createElement('canvas');
tile.width = 256;
tile.height = 256;
const ctx = tile.getContext('2d');
ctx.fillStyle = '#08121c';
ctx.fillRect(0, 0, 256, 256);
ctx.strokeStyle = 'rgba(0, 212, 255, 0.12)';
ctx.lineWidth = 1;
ctx.beginPath();
ctx.moveTo(0, 0);
ctx.lineTo(256, 0);
ctx.moveTo(0, 0);
ctx.lineTo(0, 256);
ctx.stroke();
ctx.strokeStyle = 'rgba(255, 255, 255, 0.06)';
ctx.beginPath();
ctx.moveTo(128, 0);
ctx.lineTo(128, 256);
ctx.moveTo(0, 128);
ctx.lineTo(256, 128);
ctx.stroke();
ctx.fillStyle = 'rgba(160, 220, 255, 0.28)';
ctx.font = '11px "JetBrains Mono", monospace';
ctx.fillText(`Z${coords.z} X${coords.x} Y${coords.y}`, 12, 22);
return tile;
};
return layer;
}
async function initGroundMap() {
groundMap = L.map('groundMap', {
center: [20, 0],
zoom: 2,
minZoom: 1,
maxZoom: 10,
worldCopyJump: true
});
window.groundMap = groundMap;
// Use a zero-network fallback so dashboard navigation stays fast even
// when internet map providers are slow or unreachable.
const fallbackTiles = createFallbackGridLayer().addTo(groundMap);
// Upgrade tiles in background via Settings (with timeout fallback)
if (typeof Settings !== 'undefined') {
try {
await Promise.race([
Settings.init(),
new Promise((_, reject) => setTimeout(() => reject(new Error('Settings timeout')), 5000))
]);
groundMap.removeLayer(fallbackTiles);
Settings.createTileLayer().addTo(groundMap);
Settings.registerMap(groundMap);
} catch (e) {
console.warn('Satellite: Settings init failed/timed out, using fallback tiles:', e);
}
}
const lat = parseFloat(document.getElementById('obsLat')?.value);
const lon = parseFloat(document.getElementById('obsLon')?.value);
if (!Number.isNaN(lat) && !Number.isNaN(lon)) {
groundMap.setView([lat, lon], 3);
}
}
function getLocation() {
if (navigator.geolocation) {
navigator.geolocation.getCurrentPosition(pos => {
const lat = pos.coords.latitude;
const lon = pos.coords.longitude;
document.getElementById('obsLat').value = lat.toFixed(4);
document.getElementById('obsLon').value = lon.toFixed(4);
if (window.ObserverLocation && ObserverLocation.isSharedEnabled()) {
ObserverLocation.setShared({ lat, lon });
}
calculatePasses();
}, () => {
calculatePasses();
});
} else {
calculatePasses();
}
}
async function calculatePasses() {
const requestId = ++_passRequestId;
const lat = parseFloat(document.getElementById('obsLat').value);
const lon = parseFloat(document.getElementById('obsLon').value);
const container = document.getElementById('passList');
const button = document.querySelector('.controls-bar .btn.primary');
if (container) {
container.innerHTML = '<div style="text-align:center;color:var(--text-secondary);padding:20px;">Calculating passes...</div>';
}
if (button) {
button.disabled = true;
button.textContent = 'WORKING...';
}
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 (requestId !== _passRequestId) return;
if (data.status === 'success') {
passes = data.passes;
renderPassList();
updateStats();
if (passes.length > 0) {
selectPass(0);
} else {
clearTelemetry();
}
updateObserverMarker(lat, lon);
document.getElementById('trackingStatus').textContent = 'TRACKING';
document.getElementById('trackingDot').style.background = 'var(--accent-green)';
} else {
passes = [];
renderPassList();
document.getElementById('trackingStatus').textContent = 'ERROR';
document.getElementById('trackingDot').style.background = 'var(--accent-red)';
}
} catch (err) {
if (requestId !== _passRequestId) return;
console.error('Pass calculation error:', err);
passes = [];
renderPassList();
document.getElementById('trackingStatus').textContent = 'OFFLINE';
document.getElementById('trackingDot').style.background = 'var(--accent-red)';
} finally {
if (requestId === _passRequestId && button) {
button.disabled = false;
button.textContent = 'CALCULATE';
}
}
}
// Satellites that can be handed off to the weather-satellite capture mode
const WEATHER_SAT_KEYS = new Set([
'METEOR-M2-3', 'METEOR-M2-4'
]);
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;
}
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 aosAz = pass.aosAz != null ? pass.aosAz.toFixed(0) + '°' : '--';
const tcaEl = pass.tcaEl != null ? pass.tcaEl.toFixed(0) + '°' : (pass.maxEl != null ? pass.maxEl.toFixed(0) + '°' : '--');
const tcaAz = pass.tcaAz != null ? pass.tcaAz.toFixed(0) + '°' : '--';
const losAz = pass.losAz != null ? pass.losAz.toFixed(0) + '°' : '--';
const timeStr = (pass.aosTime || pass.startTime || '').split('T')[1]?.substring(0, 5) || pass.startTime?.split(' ')[1] || '--:--';
const isWeatherSat = WEATHER_SAT_KEYS.has(pass.satellite);
const captureBtn = isWeatherSat
? `<button class="pass-capture-btn" onclick="event.stopPropagation(); handoffToWeatherSat(${idx})" title="Switch to Weather Satellite mode for this pass">→ Capture</button>`
: '';
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">${timeStr} UTC</span>
<span>${tcaEl} · ${pass.duration} min</span>
</div>
<div class="pass-event-row">
<span title="AOS azimuth">↑ ${aosAz}</span>
<span title="TCA azimuth">⊙ ${tcaAz}</span>
<span title="LOS azimuth">↓ ${losAz}</span>
${captureBtn}
</div>
</div>
`;
}).join('');
}
function handoffToWeatherSat(passIdx) {
const pass = passes[passIdx];
if (!pass) return;
const msg = {
type: 'weather-sat-handoff',
satellite: pass.satellite,
aosTime: pass.aosTime || pass.startTimeISO,
tcaEl: pass.tcaEl ?? pass.maxEl,
duration: pass.duration,
};
// Prefer parent (embedded iframe), fall back to opener (new window)
const target = window.parent !== window ? window.parent : window.opener;
if (target) {
target.postMessage(msg, '*');
}
}
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.max(10, Math.min(cx, cy) - 40);
const cs = getComputedStyle(document.documentElement);
const bgColor = cs.getPropertyValue('--bg-card').trim() || '#0a0a0f';
const accentCyan = cs.getPropertyValue('--accent-cyan').trim() || '#00d4ff';
const accentRed = cs.getPropertyValue('--accent-red').trim() || '#ff4444';
const textDim = cs.getPropertyValue('--text-dim').trim() || 'rgba(0,212,255,0.4)';
ctx.fillStyle = bgColor;
ctx.fillRect(0, 0, canvas.width, canvas.height);
// Elevation rings
ctx.strokeStyle = accentCyan + '26'; // ~15% opacity
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();
ctx.fillStyle = textDim;
ctx.font = '10px Roboto Condensed';
ctx.fillText(el + '°', cx + 5, cy - r + 12);
}
// Horizon
ctx.strokeStyle = accentCyan + '4D'; // ~30% opacity
ctx.lineWidth = 2;
ctx.beginPath();
ctx.arc(cx, cy, radius, 0, Math.PI * 2);
ctx.stroke();
// Cardinal lines
ctx.strokeStyle = accentCyan + '1A'; // ~10% opacity
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: accentRed },
{ text: 'E', az: 90, color: accentCyan },
{ text: 'S', az: 180, color: accentCyan },
{ text: 'W', az: 270, color: accentCyan }
];
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);
});
// 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([]);
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();
}
}
}
function updateGroundTrack(pass) {
if (!groundMap) return;
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) {
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];
const crossesAntimeridian = (prevLon > 90 && p.lon < -90) || (prevLon < -90 && p.lon > 90);
if (crossesAntimeridian) {
if (currentSegment.length >= 1) segments.push(currentSegment);
currentSegment = [];
}
}
currentSegment.push([p.lat, p.lon]);
}
if (currentSegment.length >= 1) segments.push(currentSegment);
trackLine = L.layerGroup();
const allCoords = [];
segments.forEach(seg => {
L.polyline(seg, {
color: pass.color || '#00d4ff',
weight: 4,
opacity: 1.0
}).addTo(trackLine);
allCoords.push(...seg);
});
trackLine.addTo(groundMap);
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`);
}
if (allCoords.length > 0) {
groundMap.fitBounds(L.latLngBounds(allCoords), { padding: [30, 30] });
}
}
}
function updateObserverMarker(lat, lon) {
if (!groundMap) return;
if (observerMarker) groundMap.removeLayer(observerMarker);
// Determine location label
let locationLabel = 'Local Observer';
if (currentLocationSource && currentLocationSource.startsWith('agent-')) {
const agentId = currentLocationSource.replace('agent-', '');
const agent = agents.find(a => a.id == agentId);
if (agent) {
locationLabel = agent.name;
}
}
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(`<b>${locationLabel}</b><br>${lat.toFixed(4)}°, ${lon.toFixed(4)}°`);
}
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) {
const start = new Date(pass.startTimeISO);
if (start > now) {
nextPass = pass;
break;
}
}
if (!nextPass) nextPass = passes[0];
document.getElementById('countdownSat').textContent = nextPass.satellite;
const passTime = new Date(nextPass.startTimeISO);
const diff = passTime - now;
if (diff <= 0) {
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');
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'));
}
}
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.max(10, Math.min(cx, cy) - 40);
const cs = getComputedStyle(document.documentElement);
const bgColor = cs.getPropertyValue('--bg-card').trim() || '#0a0a0f';
const accentCyan = cs.getPropertyValue('--accent-cyan').trim() || '#00d4ff';
const accentRed = cs.getPropertyValue('--accent-red').trim() || '#ff4444';
const accentGreen = cs.getPropertyValue('--accent-green').trim() || '#00ff88';
const textPrimary = cs.getPropertyValue('--text-primary').trim() || '#fff';
const textDim = cs.getPropertyValue('--text-dim').trim() || 'rgba(0,212,255,0.4)';
ctx.fillStyle = bgColor;
ctx.fillRect(0, 0, canvas.width, canvas.height);
ctx.strokeStyle = accentCyan + '26';
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 = textDim;
ctx.font = '10px Roboto Condensed';
ctx.fillText(elRing + '°', cx + 5, cy - r + 12);
}
ctx.strokeStyle = accentCyan + '4D';
ctx.lineWidth = 2;
ctx.beginPath();
ctx.arc(cx, cy, radius, 0, Math.PI * 2);
ctx.stroke();
ctx.strokeStyle = accentCyan + '1A';
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();
}
ctx.font = 'bold 14px Orbitron';
const labels = [
{ text: 'N', az: 0, color: accentRed },
{ text: 'E', az: 90, color: accentCyan },
{ text: 'S', az: 180, color: accentCyan },
{ text: 'W', az: 270, color: accentCyan }
];
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);
});
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);
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;
ctx.beginPath();
ctx.arc(x, y, 10, 0, Math.PI * 2);
ctx.fillStyle = color;
ctx.fill();
ctx.strokeStyle = textPrimary;
ctx.lineWidth = 3;
ctx.stroke();
ctx.font = 'bold 11px Orbitron';
ctx.fillStyle = textPrimary;
ctx.textAlign = 'center';
ctx.fillText(satellites[selectedSatellite]?.name || 'SAT', x, y - 20);
ctx.font = '10px Roboto Condensed';
ctx.fillStyle = el > 0 ? accentGreen : accentRed;
ctx.fillText(el.toFixed(1) + '°', x, y + 25);
} else {
ctx.font = '12px Rajdhani';
ctx.fillStyle = accentRed;
ctx.textAlign = 'center';
ctx.fillText('BELOW HORIZON', cx, cy + radius + 35);
}
}
async function loadTransmitters(noradId) {
const container = document.getElementById('transmittersList');
const countEl = document.getElementById('txCount');
if (!container) return;
const requestId = ++_txRequestId;
if (!noradId) {
container.innerHTML = '<div style="text-align:center;color:var(--text-secondary);padding:15px;font-size:11px;">Select a satellite</div>';
if (countEl) countEl.textContent = '';
return;
}
container.innerHTML = '<div style="text-align:center;color:var(--text-secondary);padding:15px;font-size:11px;">Loading...</div>';
try {
const controller = new AbortController();
const timeout = setTimeout(() => controller.abort(), 7000);
const r = await fetch(`/satellite/transmitters/${noradId}`, { signal: controller.signal });
clearTimeout(timeout);
if (!r.ok) throw new Error(`HTTP ${r.status}`);
const data = await r.json();
if (requestId !== _txRequestId) return;
if (data.status !== 'success') throw new Error('Unexpected response');
renderTransmitters(data.transmitters || []);
} catch (e) {
if (requestId !== _txRequestId) return;
const timedOut = e && (e.name === 'AbortError' || String(e).includes('AbortError'));
container.innerHTML = `<div style="text-align:center;color:var(--text-secondary);padding:15px;font-size:11px;">${timedOut ? 'Timed out loading transmitter data' : 'Failed to load transmitter data'}</div>`;
if (countEl) countEl.textContent = '';
}
}
function renderTransmitters(txList) {
const container = document.getElementById('transmittersList');
const countEl = document.getElementById('txCount');
if (!container) return;
const active = txList.filter(t => t.status === 'active');
const all = txList;
if (countEl) countEl.textContent = all.length ? `(${active.length}/${all.length})` : '';
if (!all.length) {
container.innerHTML = '<div style="text-align:center;color:var(--text-secondary);padding:15px;font-size:11px;">No transmitter data available</div>';
return;
}
container.innerHTML = all.map(tx => {
const isActive = tx.status === 'active';
const dl = tx.downlink_low != null ? tx.downlink_low.toFixed(3) + ' MHz' : null;
const dlHigh = tx.downlink_high != null && tx.downlink_high !== tx.downlink_low ? '' + tx.downlink_high.toFixed(3) : '';
const ul = tx.uplink_low != null ? tx.uplink_low.toFixed(3) + ' MHz' : null;
const baud = tx.baud ? ` · ${tx.baud} Bd` : '';
const mode = tx.mode || '';
return `<div class="tx-item ${isActive ? 'tx-active' : 'tx-inactive'}">
<div class="tx-status-dot" style="background:${isActive ? 'var(--accent-green)' : '#444'};"></div>
<div class="tx-body">
<div class="tx-desc">${tx.description || 'Unknown'}</div>
${dl ? `<div class="tx-freq">↓ ${dl}${dlHigh} ${mode}${baud}</div>` : ''}
${ul ? `<div class="tx-freq tx-uplink">↑ ${ul}</div>` : ''}
<div class="tx-service">${tx.service || ''} ${tx.type || ''}</div>
</div>
</div>`;
}).join('');
}
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.max(10, Math.min(cx, cy) - 40);
const cs = getComputedStyle(document.documentElement);
const accentRed = cs.getPropertyValue('--accent-red').trim() || '#ff4444';
const accentGreen = cs.getPropertyValue('--accent-green').trim() || '#00ff88';
const textPrimary = cs.getPropertyValue('--text-primary').trim() || '#fff';
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);
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;
ctx.beginPath();
ctx.arc(x, y, 10, 0, Math.PI * 2);
ctx.fillStyle = color;
ctx.fill();
ctx.strokeStyle = textPrimary;
ctx.lineWidth = 3;
ctx.stroke();
ctx.font = 'bold 11px Orbitron';
ctx.fillStyle = textPrimary;
ctx.textAlign = 'center';
ctx.fillText(satellites[selectedSatellite]?.name || 'SAT', x, y - 20);
ctx.font = '10px Roboto Condensed';
ctx.fillStyle = el > 0 ? accentGreen : accentRed;
ctx.fillText(el.toFixed(1) + '°', x, y + 25);
}
}
</script>
<!-- Settings Modal -->
{% include 'partials/settings-modal.html' %}
<!-- Help Modal -->
{% include 'partials/help-modal.html' %}
<script src="{{ url_for('static', filename='js/core/settings-manager.js') }}?v={{ version }}&r=maptheme17"></script>
<script src="{{ url_for('static', filename='js/core/global-nav.js') }}"></script>
<script src="{{ url_for('static', filename='js/modes/ground_station_waterfall.js') }}"></script>
<script>
// -------------------------------------------------------------------------
// Ground Station Panel — inline controller
// -------------------------------------------------------------------------
(function () {
'use strict';
let _gsEnabled = false;
let _gsEventSource = null;
let _editingNorad = null; // norad_id being edited, or null for new
// -----------------------------------------------------------------------
// Init
// -----------------------------------------------------------------------
function gsInit() {
gsLoadStatus();
gsLoadProfiles();
gsLoadUpcoming();
gsLoadRecordings();
gsConnectSSE();
}
// -----------------------------------------------------------------------
// Scheduler status
// -----------------------------------------------------------------------
function gsLoadStatus() {
fetch('/ground_station/scheduler/status')
.then(r => r.json())
.then(data => { _gsEnabled = data.enabled; _applyStatus(data); })
.catch(() => {});
}
function _applyStatus(data) {
const statusEl = document.getElementById('gsSchedulerStatus');
const enableBtn = document.getElementById('gsEnableBtn');
const disableBtn = document.getElementById('gsDisableBtn');
const stopBtn = document.getElementById('gsStopBtn');
const activeRow = document.getElementById('gsActiveRow');
const indicator = document.getElementById('gsIndicator');
if (!statusEl) return;
_gsEnabled = data.enabled;
statusEl.textContent = data.enabled
? (data.active_observation ? 'CAPTURING' : 'ACTIVE') : 'IDLE';
statusEl.style.color = data.enabled
? (data.active_observation ? 'var(--accent-green)' : 'var(--accent-cyan)')
: 'var(--text-secondary)';
if (indicator) {
indicator.style.background = data.enabled
? (data.active_observation ? '#00ff88' : '#00d4ff') : '';
}
if (enableBtn) enableBtn.style.display = data.enabled ? 'none' : '';
if (disableBtn) disableBtn.style.display = data.enabled ? '' : 'none';
if (stopBtn) stopBtn.style.display = data.active_observation ? '' : 'none';
if (activeRow) {
activeRow.style.display = data.active_observation ? '' : 'none';
if (data.active_observation) {
const satEl = document.getElementById('gsActiveSat');
if (satEl) satEl.textContent = data.active_observation.satellite || '-';
}
}
}
// -----------------------------------------------------------------------
// Observation profiles
// -----------------------------------------------------------------------
function gsLoadProfiles() {
fetch('/ground_station/profiles')
.then(r => r.json())
.then(profiles => _renderProfiles(profiles))
.catch(() => { _renderProfiles([]); });
}
function _renderProfiles(profiles) {
const el = document.getElementById('gsProfileList');
if (!el) return;
if (!profiles.length) {
el.innerHTML = '<div style="text-align:center;color:var(--text-secondary);font-size:10px;padding:6px 0;">No profiles — click + ADD to create one</div>';
return;
}
el.innerHTML = profiles.map(p => {
const enCls = p.enabled ? 'gs-profile-enabled' : '';
const taskSummary = _formatTaskSummary(p.tasks || []);
return `<div class="gs-profile-item">
<span class="prof-name ${enCls}" title="NORAD ${p.norad_id}${taskSummary ? ' • ' + taskSummary : ''}">${_esc(p.name)}</span>
<span class="prof-freq">${(+p.frequency_mhz).toFixed(3)}</span>
<div class="prof-actions">
<button onclick="gsEditProfile(${p.norad_id})">EDIT</button>
<button class="del" onclick="gsDeleteProfile(${p.norad_id})">✕</button>
</div>
</div>`;
}).join('');
}
window.gsShowProfileForm = function (norad, name, freqMhz, decoder, minEl, gain, recordIQ, tasks) {
_editingNorad = norad || null;
const form = document.getElementById('gsProfileForm');
const title = document.getElementById('gsProfileFormTitle');
const err = document.getElementById('gsProfileError');
if (!form) return;
// Pre-fill with provided values OR pull from current satellite selection
const noradId = norad || (typeof selectedSatellite !== 'undefined' ? selectedSatellite : '');
const satName = name || (typeof satellites !== 'undefined' && satellites[noradId] ? satellites[noradId].name : 'Unknown');
const freq = freqMhz != null ? freqMhz : _guessFrequency();
document.getElementById('gsProfNorad').value = noradId;
document.getElementById('gsProfSatName').textContent = satName;
document.getElementById('gsProfFreq').value = freq != null ? freq : '';
document.getElementById('gsProfDecoder').value = decoder || _guessDecoder();
document.getElementById('gsProfMinEl').value = minEl != null ? minEl : 10;
document.getElementById('gsProfGain').value = gain != null ? gain : 40;
document.getElementById('gsProfRecordIQ').checked = !!recordIQ;
_applyTaskSelection(Array.isArray(tasks) ? tasks : _tasksFromLegacyDecoder(decoder || _guessDecoder(), !!recordIQ));
if (title) title.textContent = _editingNorad ? 'EDIT PROFILE' : 'NEW PROFILE';
if (err) { err.style.display = 'none'; err.textContent = ''; }
form.style.display = '';
document.getElementById('gsAddProfileBtn').style.display = 'none';
};
window.gsEditProfile = function (norad) {
fetch(`/ground_station/profiles/${norad}`)
.then(r => r.json())
.then(p => {
gsShowProfileForm(p.norad_id, p.name, p.frequency_mhz,
p.decoder_type, p.min_elevation, p.gain, p.record_iq, p.tasks);
})
.catch(() => {});
};
window.gsHideProfileForm = function () {
const form = document.getElementById('gsProfileForm');
if (form) form.style.display = 'none';
document.getElementById('gsAddProfileBtn').style.display = '';
_editingNorad = null;
};
window.gsSaveProfile = function () {
const norad = parseInt(document.getElementById('gsProfNorad').value);
const freq = parseFloat(document.getElementById('gsProfFreq').value);
const errEl = document.getElementById('gsProfileError');
if (!norad || isNaN(norad)) { _showFormErr('Select a satellite first'); return; }
if (!freq || isNaN(freq)) { _showFormErr('Enter a valid frequency'); return; }
const name = document.getElementById('gsProfSatName').textContent || `SAT-${norad}`;
const tasks = _collectSelectedTasks();
const payload = {
norad_id: norad,
name: name,
frequency_mhz: freq,
decoder_type: document.getElementById('gsProfDecoder').value,
min_elevation: parseFloat(document.getElementById('gsProfMinEl').value) || 10,
gain: parseFloat(document.getElementById('gsProfGain').value) || 40,
record_iq: document.getElementById('gsProfRecordIQ').checked,
enabled: true,
tasks: tasks,
};
fetch('/ground_station/profiles', {
method: 'POST',
headers: {'Content-Type': 'application/json'},
body: JSON.stringify(payload),
})
.then(r => r.ok ? r.json() : r.json().then(e => { throw new Error(e.error || 'Save failed'); }))
.then(() => { gsHideProfileForm(); gsLoadProfiles(); })
.catch(e => _showFormErr(e.message || 'Save failed'));
};
window.gsDeleteProfile = function (norad) {
if (!confirm(`Delete profile for NORAD ${norad}?`)) return;
fetch(`/ground_station/profiles/${norad}`, {method: 'DELETE'})
.then(() => gsLoadProfiles())
.catch(() => {});
};
function _showFormErr(msg) {
const el = document.getElementById('gsProfileError');
if (!el) return;
el.textContent = msg;
el.style.display = '';
}
function _tasksFromLegacyDecoder(decoder, recordIQ) {
const tasks = [];
const value = String(decoder || 'fm').toLowerCase();
if (value === 'afsk' || value === 'fm') tasks.push('telemetry_ax25');
else if (value === 'gmsk') tasks.push('telemetry_gmsk');
else if (value === 'bpsk') tasks.push('telemetry_bpsk');
if (recordIQ || value === 'iq_only') tasks.push('record_iq');
return tasks;
}
function _collectSelectedTasks() {
const tasks = [];
if (document.getElementById('gsTaskTelemetryAx25')?.checked) tasks.push('telemetry_ax25');
if (document.getElementById('gsTaskTelemetryGmsk')?.checked) tasks.push('telemetry_gmsk');
if (document.getElementById('gsTaskTelemetryBpsk')?.checked) tasks.push('telemetry_bpsk');
if (document.getElementById('gsTaskWeatherMeteor')?.checked) tasks.push('weather_meteor_lrpt');
if (document.getElementById('gsTaskRecordIq')?.checked || document.getElementById('gsProfRecordIQ')?.checked) tasks.push('record_iq');
return tasks;
}
function _applyTaskSelection(tasks) {
const set = new Set(tasks || []);
const ax25 = document.getElementById('gsTaskTelemetryAx25');
const gmsk = document.getElementById('gsTaskTelemetryGmsk');
const bpsk = document.getElementById('gsTaskTelemetryBpsk');
const meteor = document.getElementById('gsTaskWeatherMeteor');
const recordIq = document.getElementById('gsTaskRecordIq');
if (ax25) ax25.checked = set.has('telemetry_ax25');
if (gmsk) gmsk.checked = set.has('telemetry_gmsk');
if (bpsk) bpsk.checked = set.has('telemetry_bpsk');
if (meteor) meteor.checked = set.has('weather_meteor_lrpt');
if (recordIq) recordIq.checked = set.has('record_iq');
}
function _formatTaskSummary(tasks) {
const labels = [];
const set = new Set(tasks || []);
if (set.has('telemetry_ax25')) labels.push('AX25');
if (set.has('telemetry_gmsk')) labels.push('GMSK');
if (set.has('telemetry_bpsk')) labels.push('BPSK');
if (set.has('weather_meteor_lrpt')) labels.push('METEOR');
if (set.has('record_iq')) labels.push('IQ');
return labels.join(', ');
}
// Try to get a sensible default frequency from the SatNOGS transmitter list
function _guessFrequency() {
const items = document.querySelectorAll('#transmittersList .tx-item');
for (const item of items) {
const freq = item.querySelector('.tx-freq');
if (!freq) continue;
const m = freq.textContent.match(/↓\s*([\d.]+)/);
if (m) return parseFloat(m[1]);
}
return null;
}
function _guessDecoder() {
const items = document.querySelectorAll('#transmittersList .tx-item');
for (const item of items) {
const freq = item.querySelector('.tx-freq');
if (!freq) continue;
const txt = freq.textContent.toLowerCase();
if (txt.includes('ax.25') || txt.includes('afsk')) return 'afsk';
if (txt.includes('gmsk')) return 'gmsk';
if (txt.includes('bpsk')) return 'bpsk';
}
return 'fm';
}
// Update form satellite when user changes the satellite dropdown
window.gsOnSatelliteChange = function () {
const form = document.getElementById('gsProfileForm');
if (!form || form.style.display === 'none' || _editingNorad) return;
// Re-open form with new satellite context
gsShowProfileForm();
};
// -----------------------------------------------------------------------
// Upcoming passes
// -----------------------------------------------------------------------
function gsLoadUpcoming() {
fetch('/ground_station/scheduler/observations')
.then(r => r.json())
.then(obs => {
const el = document.getElementById('gsUpcomingList');
if (!el) return;
const upcoming = obs.filter(o => o.status === 'scheduled').slice(0, 5);
if (!upcoming.length) {
el.innerHTML = '<div style="text-align:center;color:var(--text-secondary);font-size:10px;padding:4px 0;">No observations scheduled.<br>Enable scheduler to auto-observe.</div>';
return;
}
el.innerHTML = upcoming.map(o => {
const dt = new Date(o.aos);
const timeStr = dt.toUTCString().replace('GMT','UTC').slice(17,25);
return `<div class="gs-obs-item">
<span class="sat-name">${_esc(o.satellite)}</span>
<span class="obs-time">${timeStr} / ${(+o.max_el).toFixed(0)}°</span>
</div>`;
}).join('');
})
.catch(() => {});
}
// -----------------------------------------------------------------------
// Recordings
// -----------------------------------------------------------------------
function gsLoadRecordings() {
fetch('/ground_station/recordings')
.then(r => r.json())
.then(recs => {
const panel = document.getElementById('gsRecordingsPanel');
const list = document.getElementById('gsRecordingsList');
if (!panel || !list) return;
if (!recs.length) { panel.style.display = 'none'; return; }
panel.style.display = '';
list.innerHTML = recs.slice(0, 10).map(r => {
const kb = Math.round((r.size_bytes || 0) / 1024);
const fname = (r.sigmf_data_path || '').split('/').pop();
return `<div class="gs-recording-item">
<a href="/ground_station/recordings/${r.id}/download/data" title="${_esc(fname)}">${_esc(fname.slice(0, 22))}</a>
<span style="color:var(--text-secondary);font-size:9px;">${kb} KB</span>
</div>`;
}).join('');
})
.catch(() => {});
}
function gsLoadOutputs() {
const norad = typeof selectedSatellite !== 'undefined' ? selectedSatellite : null;
const panel = document.getElementById('gsOutputsPanel');
const list = document.getElementById('gsOutputsList');
const status = document.getElementById('gsDecodeStatus');
if (!panel || !list || !norad) return;
gsLoadDecodeJobs(norad);
fetch(`/ground_station/outputs?norad_id=${encodeURIComponent(norad)}&type=image`)
.then(r => r.json())
.then(outputs => {
if (!Array.isArray(outputs) || !outputs.length) {
if (!status || !status.textContent) {
panel.style.display = 'none';
if (status) status.style.display = 'none';
}
return;
}
panel.style.display = '';
list.innerHTML = outputs.slice(0, 10).map(o => {
const meta = o.metadata || {};
const filename = (o.file_path || '').split('/').pop() || `output-${o.id}`;
const product = meta.product ? _esc(String(meta.product)) : 'Image';
return `<div class="gs-recording-item">
<a href="/ground_station/outputs/${o.id}/download" title="${_esc(filename)}">${_esc(filename.slice(0, 24))}</a>
<span style="color:var(--text-secondary);font-size:9px;">${product}</span>
</div>`;
}).join('');
})
.catch(() => {});
}
window.gsLoadOutputs = gsLoadOutputs;
function gsLoadDecodeJobs(norad) {
const panel = document.getElementById('gsOutputsPanel');
const status = document.getElementById('gsDecodeStatus');
if (!panel || !status || !norad) return;
fetch(`/ground_station/decode-jobs?norad_id=${encodeURIComponent(norad)}&backend=meteor_lrpt&limit=1`)
.then(r => r.json())
.then(jobs => {
if (!Array.isArray(jobs) || !jobs.length) return;
const job = jobs[0];
const details = job.details || {};
const message = _formatDecodeJobSummary(job, details);
if (!message) return;
const meta = _formatDecodeJobMeta(details);
status.textContent = meta ? `${message}${meta}` : message;
status.style.display = '';
panel.style.display = '';
})
.catch(() => {});
}
function _updateDecodeStatus(data) {
const panel = document.getElementById('gsOutputsPanel');
const status = document.getElementById('gsDecodeStatus');
if (!panel || !status) return;
if (data && data.norad_id && parseInt(data.norad_id) !== parseInt(selectedSatellite)) return;
if (!data) {
status.textContent = '';
status.style.display = 'none';
return;
}
const message = data.message || data.status || '';
if (!message) {
status.textContent = '';
status.style.display = 'none';
return;
}
status.textContent = message;
panel.style.display = '';
status.style.display = '';
if (data.type === 'weather_decode_complete' || data.type === 'weather_decode_failed') {
setTimeout(() => {
if (status.textContent === message) {
status.textContent = '';
status.style.display = 'none';
}
}, 8000);
}
}
function _formatDecodeJobSummary(job, details) {
if (job.status === 'queued') return 'Decode queued';
if (job.status === 'decoding') return details.message || 'Decode in progress';
if (job.status === 'complete') {
const count = details.output_count;
return count ? `Decode complete (${count} image${count === 1 ? '' : 's'})` : 'Decode complete';
}
if (job.status === 'failed') {
const reasonLabels = {
sample_rate_too_low: 'Sample rate too low',
invalid_sample_rate: 'Sample rate rejected',
recording_too_small: 'Recording too small',
satdump_failed: 'SatDump failed',
permission_error: 'Permission error',
input_missing: 'Input not accessible',
missing_recording: 'Recording missing',
no_imagery_produced: 'No imagery produced',
};
return job.error_message || reasonLabels[details.reason] || details.message || 'Decode failed';
}
return '';
}
function _formatDecodeJobMeta(details) {
const parts = [];
if (details.sample_rate) parts.push(`${Number(details.sample_rate).toLocaleString()} Hz`);
if (details.file_size_human) parts.push(details.file_size_human);
return parts.join(' / ');
}
// -----------------------------------------------------------------------
// SSE
// -----------------------------------------------------------------------
function gsConnectSSE() {
if (_gsEventSource) _gsEventSource.close();
_gsEventSource = new EventSource('/ground_station/stream');
_gsEventSource.onmessage = (evt) => {
try {
const data = JSON.parse(evt.data);
if (data.type === 'keepalive') return;
_handleGSEvent(data);
} catch (e) {}
};
_gsEventSource.onerror = () => { setTimeout(gsConnectSSE, 5000); };
}
function _handleGSEvent(data) {
switch (data.type) {
case 'observation_started':
gsLoadStatus(); gsLoadUpcoming(); break;
case 'observation_complete':
case 'observation_failed':
case 'observation_skipped':
gsLoadStatus(); gsLoadUpcoming(); gsLoadRecordings(); gsLoadOutputs(); break;
case 'iq_bus_started':
_showWaterfall(true);
if (window.GroundStationWaterfall) {
GroundStationWaterfall.setCenterFreq(data.center_mhz, data.span_mhz);
GroundStationWaterfall.connect();
}
break;
case 'iq_bus_stopped':
setTimeout(() => _showWaterfall(false), 500);
if (window.GroundStationWaterfall) GroundStationWaterfall.disconnect();
break;
case 'doppler_update': _updateDoppler(data); break;
case 'recording_complete': gsLoadRecordings(); break;
case 'weather_decode_started':
case 'weather_decode_progress':
_updateDecodeStatus(data); break;
case 'weather_decode_complete':
case 'weather_decode_failed':
_updateDecodeStatus(data);
gsLoadOutputs(); break;
case 'packet_decoded': _appendPacket(data); break;
}
}
function _showWaterfall(show) {
const panel = document.getElementById('gsWaterfallPanel');
if (panel) panel.style.display = show ? '' : 'none';
}
function _updateDoppler(data) {
const row = document.getElementById('gsDopplerRow');
const el = document.getElementById('gsDopplerShift');
if (!row || !el) return;
row.style.display = '';
const hz = Math.round(data.shift_hz || 0);
el.textContent = (hz >= 0 ? '+' : '') + hz + ' Hz';
}
function _appendPacket(data) {
const list = document.getElementById('packetList');
if (!list) return;
const placeholder = list.querySelector('div[style*="text-align:center"]');
if (placeholder) placeholder.remove();
const item = document.createElement('div');
item.style.cssText = 'padding:4px 6px;border-bottom:1px solid rgba(0,212,255,0.08);font-size:10px;font-family:var(--font-mono);word-break:break-all;';
const protocol = data.protocol ? `<div style="color:var(--accent-cyan);margin-bottom:2px;">${_esc(String(data.protocol))}${data.source ? ' / ' + _esc(String(data.source)) : ''}</div>` : '';
const parsed = data.parsed ? `<pre style="white-space:pre-wrap;margin:2px 0 4px 0;color:var(--text-primary);font-family:var(--font-mono);font-size:9px;">${_esc(JSON.stringify(data.parsed, null, 2))}</pre>` : '';
const raw = data.data ? `<div style="color:var(--text-secondary);">${_esc(String(data.data))}</div>` : '';
item.innerHTML = protocol + parsed + raw;
list.prepend(item);
const countEl = document.getElementById('packetCount');
if (countEl) { const n = parseInt(countEl.textContent) || 0; countEl.textContent = n + 1; }
while (list.children.length > 100) list.removeChild(list.lastChild);
}
// -----------------------------------------------------------------------
// Button handlers (global scope)
// -----------------------------------------------------------------------
window.gsEnableScheduler = function () {
const lat = parseFloat(document.getElementById('obsLat')?.value || 0);
const lon = parseFloat(document.getElementById('obsLon')?.value || 0);
fetch('/ground_station/scheduler/enable', {
method: 'POST',
headers: {'Content-Type': 'application/json'},
body: JSON.stringify({lat, lon}),
})
.then(r => r.json())
.then(d => { _applyStatus(d); gsLoadUpcoming(); })
.catch(() => {});
};
window.gsDisableScheduler = function () {
fetch('/ground_station/scheduler/disable', {method: 'POST'})
.then(r => r.json())
.then(d => { _applyStatus(d); gsLoadUpcoming(); })
.catch(() => {});
};
window.gsStopActive = function () {
fetch('/ground_station/scheduler/stop', {method: 'POST'})
.then(r => r.json())
.then(d => { _applyStatus(d); })
.catch(() => {});
};
function _esc(s) {
return String(s).replace(/&/g,'&amp;').replace(/</g,'&lt;').replace(/>/g,'&gt;');
}
// Init after DOM is ready
if (document.readyState === 'loading') {
document.addEventListener('DOMContentLoaded', gsInit);
} else {
gsInit();
}
})();
</script>
</body>
</html>