Files
intercept/templates/satellite_dashboard.html
James Smith 9e7dfbda5a Fix satellite dashboard TARGET dropdown not reflecting enabled satellites
Add auto-refresh on window focus so the dropdown updates automatically when
switching back from the sidebar, plus a manual ↺ refresh button next to the
dropdown. Also preserves the current selection across refreshes.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-18 14:05:22 +00:00

1372 lines
59 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>
<!-- Fonts - Conditional CDN/Local loading -->
{% if offline_settings.fonts_source == 'local' %}
<link rel="stylesheet" href="{{ url_for('static', filename='css/fonts-local.css') }}">
{% else %}
<link href="https://fonts.googleapis.com/css2?family=Roboto+Condensed:wght@300;400;500;600;700&display=swap" rel="stylesheet">
{% endif %}
<!-- Leaflet.js - Conditional CDN/Local loading -->
{% if offline_settings.assets_source == 'local' %}
<link rel="stylesheet" href="{{ url_for('static', filename='vendor/leaflet/leaflet.css') }}">
<script src="{{ url_for('static', filename='vendor/leaflet/leaflet.js') }}"></script>
{% else %}
<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>
{% endif %}
<!-- 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="40069">METEOR-M2</option>
<option value="57166">METEOR-M2-3</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>
<!-- 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.<br>Packet decoding requires an AFSK/FSK decoder (coming soon).
</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;
}
</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 satellites = {
25544: { name: 'ISS (ZARYA)', color: '#00ffff' },
40069: { name: 'METEOR-M2', color: '#9370DB' },
57166: { name: 'METEOR-M2-3', color: '#ff00ff' }
};
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 => {
if (data.status === 'success' && data.satellites && data.satellites.length > 0) {
const prevSelected = selectedSatellite;
const newSats = {};
const select = document.getElementById('satSelect');
select.innerHTML = '';
data.satellites.forEach((sat, i) => {
const norad = parseInt(sat.norad_id);
newSats[norad] = {
name: sat.name,
color: satellites[norad]?.color || satColors[i % satColors.length]
};
const opt = document.createElement('option');
opt.value = norad;
opt.textContent = sat.name;
select.appendChild(opt);
});
satellites = newSats;
// Restore previous selection if still available, else default to ISS
if (newSats[prevSelected]) {
select.value = prevSelected;
} else if (newSats[25544]) {
select.value = '25544';
}
selectedSatellite = parseInt(select.value);
}
})
.catch(() => {});
}
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; }
}
loadTransmitters(selectedSatellite);
calculatePasses();
}
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 =>
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) 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 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();
}
loadAgents();
loadTransmitters(selectedSatellite);
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';
}
async function initGroundMap() {
groundMap = L.map('groundMap', {
center: [20, 0],
zoom: 2,
minZoom: 1,
maxZoom: 10,
worldCopyJump: true
});
window.groundMap = groundMap;
// Add fallback tiles immediately so the map is visible instantly
const fallbackTiles = L.tileLayer('https://cartodb-basemaps-{s}.global.ssl.fastly.net/dark_all/{z}/{x}/{y}.png', {
attribution: '&copy; <a href="https://www.openstreetmap.org/copyright">OSM</a> &copy; <a href="https://carto.com/">CARTO</a>',
maxZoom: 19,
subdomains: 'abcd',
className: 'tile-layer-cyan'
}).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 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);
document.getElementById('trackingStatus').textContent = 'TRACKING';
document.getElementById('trackingDot').style.background = 'var(--accent-green)';
} else {
document.getElementById('trackingStatus').textContent = 'ERROR';
document.getElementById('trackingDot').style.background = 'var(--accent-red)';
}
} catch (err) {
console.error('Pass calculation error:', err);
document.getElementById('trackingStatus').textContent = 'OFFLINE';
document.getElementById('trackingDot').style.background = 'var(--accent-red)';
}
}
// Satellites that can be handed off to the weather-satellite capture mode
const WEATHER_SAT_KEYS = new Set([
'NOAA-15', 'NOAA-18', 'NOAA-19', 'NOAA-20', 'NOAA-21',
'METEOR-M2', '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;
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 r = await fetch(`/satellite/transmitters/${noradId}`);
const data = await r.json();
renderTransmitters(data.transmitters || []);
} catch (e) {
container.innerHTML = '<div style="text-align:center;color:var(--text-secondary);padding:15px;font-size:11px;">Failed to load</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>
</body>
</html>