mirror of
https://github.com/smittix/intercept.git
synced 2026-04-24 06:40:00 -07:00
Replace IBM Plex Mono, Space Mono, and JetBrains Mono with Roboto Condensed across all CSS variables, inline styles, canvas ctx.font references, and Google Fonts CDN links. Updates 28 files covering templates, stylesheets, and JS modules for consistent typography. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
1126 lines
47 KiB
HTML
1126 lines
47 KiB
HTML
<!DOCTYPE html>
|
|
<html lang="en">
|
|
<head>
|
|
<meta charset="UTF-8">
|
|
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
|
<title>SATELLITE COMMAND // iNTERCEPT - See the Invisible</title>
|
|
<!-- Fonts - Conditional CDN/Local loading -->
|
|
{% if offline_settings.fonts_source == 'local' %}
|
|
<link href="https://fonts.googleapis.com/css2?family=Roboto+Condensed:wght@300;400;500;600;700&display=swap" rel="stylesheet">
|
|
{% else %}
|
|
<link href="https://fonts.googleapis.com/css2?family=JetBrains+Mono:wght@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/global-nav.css') }}">
|
|
<link rel="stylesheet" href="{{ url_for('static', filename='css/settings.css') }}">
|
|
<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>// iNTERCEPT - 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>
|
|
|
|
{% set active_mode = 'satellite' %}
|
|
{% include 'partials/nav.html' with context %}
|
|
|
|
<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>
|
|
</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>
|
|
</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;
|
|
}
|
|
</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() {
|
|
fetch('/satellite/tracked?enabled=true')
|
|
.then(r => r.json())
|
|
.then(data => {
|
|
if (data.status === 'success' && data.satellites && data.satellites.length > 0) {
|
|
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;
|
|
// Default to ISS if available
|
|
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; }
|
|
}
|
|
|
|
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;
|
|
}
|
|
|
|
document.addEventListener('DOMContentLoaded', () => {
|
|
loadDashboardSatellites();
|
|
setupEmbeddedMode();
|
|
const usedShared = applySharedObserverLocation();
|
|
initGroundMap();
|
|
updateClock();
|
|
setInterval(updateClock, 1000);
|
|
setInterval(updateCountdown, 1000);
|
|
setInterval(updateRealTimePositions, 5000);
|
|
loadAgents();
|
|
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
|
|
});
|
|
|
|
// Use settings manager for tile layer (allows runtime changes)
|
|
window.groundMap = groundMap;
|
|
if (typeof Settings !== 'undefined') {
|
|
// Wait for settings to load from server before applying tiles
|
|
await Settings.init();
|
|
Settings.createTileLayer().addTo(groundMap);
|
|
Settings.registerMap(groundMap);
|
|
} else {
|
|
L.tileLayer('https://cartodb-basemaps-{s}.global.ssl.fastly.net/dark_all/{z}/{x}/{y}.png', {
|
|
attribution: '© <a href="https://www.openstreetmap.org/copyright">OSM</a> © <a href="https://carto.com/">CARTO</a>',
|
|
maxZoom: 19,
|
|
subdomains: 'abcd',
|
|
className: 'tile-layer-cyan'
|
|
}).addTo(groundMap);
|
|
}
|
|
|
|
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)';
|
|
}
|
|
}
|
|
|
|
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 time = pass.startTime.split(' ')[1] || pass.startTime;
|
|
|
|
return `
|
|
<div class="pass-item ${selectedPass === idx ? 'active' : ''}" onclick="selectPass(${idx})">
|
|
<div class="pass-item-header">
|
|
<span class="pass-sat-name">${pass.satellite}</span>
|
|
<span class="pass-quality ${quality}">${qualityText}</span>
|
|
</div>
|
|
<div class="pass-item-details">
|
|
<span class="pass-time">${time}</span>
|
|
<span>${pass.maxEl.toFixed(0)}° · ${pass.duration} min</span>
|
|
</div>
|
|
</div>
|
|
`;
|
|
}).join('');
|
|
}
|
|
|
|
function selectPass(idx) {
|
|
selectedPass = idx;
|
|
renderPassList();
|
|
|
|
const pass = passes[idx];
|
|
if (!pass) return;
|
|
|
|
drawPolarPlot(pass);
|
|
updateGroundTrack(pass);
|
|
updateTelemetry(pass);
|
|
updateRealTimePositions(true);
|
|
}
|
|
|
|
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);
|
|
|
|
ctx.fillStyle = '#0a0a0f';
|
|
ctx.fillRect(0, 0, canvas.width, canvas.height);
|
|
|
|
// Elevation rings
|
|
ctx.strokeStyle = 'rgba(0, 212, 255, 0.15)';
|
|
ctx.lineWidth = 1;
|
|
for (let el = 30; el <= 90; el += 30) {
|
|
const r = radius * (1 - el / 90);
|
|
ctx.beginPath();
|
|
ctx.arc(cx, cy, r, 0, Math.PI * 2);
|
|
ctx.stroke();
|
|
|
|
ctx.fillStyle = 'rgba(0, 212, 255, 0.4)';
|
|
ctx.font = '10px Roboto Condensed';
|
|
ctx.fillText(el + '°', cx + 5, cy - r + 12);
|
|
}
|
|
|
|
// Horizon
|
|
ctx.strokeStyle = 'rgba(0, 212, 255, 0.3)';
|
|
ctx.lineWidth = 2;
|
|
ctx.beginPath();
|
|
ctx.arc(cx, cy, radius, 0, Math.PI * 2);
|
|
ctx.stroke();
|
|
|
|
// Cardinal lines
|
|
ctx.strokeStyle = 'rgba(0, 212, 255, 0.1)';
|
|
ctx.lineWidth = 1;
|
|
for (let az = 0; az < 360; az += 45) {
|
|
const angle = (az - 90) * Math.PI / 180;
|
|
ctx.beginPath();
|
|
ctx.moveTo(cx, cy);
|
|
ctx.lineTo(cx + radius * Math.cos(angle), cy + radius * Math.sin(angle));
|
|
ctx.stroke();
|
|
}
|
|
|
|
// Cardinal labels
|
|
ctx.font = 'bold 14px Orbitron';
|
|
const labels = [
|
|
{ text: 'N', az: 0, color: '#ff4444' },
|
|
{ text: 'E', az: 90, color: '#00d4ff' },
|
|
{ text: 'S', az: 180, color: '#00d4ff' },
|
|
{ text: 'W', az: 270, color: '#00d4ff' }
|
|
];
|
|
labels.forEach(l => {
|
|
const angle = (l.az - 90) * Math.PI / 180;
|
|
const x = cx + (radius + 20) * Math.cos(angle);
|
|
const y = cy + (radius + 20) * Math.sin(angle);
|
|
ctx.fillStyle = l.color;
|
|
ctx.textAlign = 'center';
|
|
ctx.textBaseline = 'middle';
|
|
ctx.fillText(l.text, x, y);
|
|
});
|
|
|
|
// 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'));
|
|
}
|
|
}
|
|
|
|
async function updateRealTimePositions(fitBoundsToOrbit = false) {
|
|
const lat = parseFloat(document.getElementById('obsLat').value);
|
|
const lon = parseFloat(document.getElementById('obsLon').value);
|
|
|
|
let targetSatellite = selectedSatellite;
|
|
let satColor = satellites[selectedSatellite]?.color || '#00d4ff';
|
|
|
|
if (selectedPass !== null && passes[selectedPass]) {
|
|
const pass = passes[selectedPass];
|
|
targetSatellite = pass.satellite;
|
|
satColor = pass.color || satColor;
|
|
}
|
|
|
|
try {
|
|
const response = await fetch('/satellite/position', {
|
|
method: 'POST',
|
|
headers: { 'Content-Type': 'application/json' },
|
|
body: JSON.stringify({
|
|
latitude: lat,
|
|
longitude: lon,
|
|
satellites: [targetSatellite],
|
|
includeTrack: true
|
|
})
|
|
});
|
|
|
|
const data = await response.json();
|
|
if (data.status === 'success' && data.positions.length > 0) {
|
|
const pos = data.positions[0];
|
|
|
|
document.getElementById('telLat').textContent = pos.lat.toFixed(4) + '°';
|
|
document.getElementById('telLon').textContent = pos.lon.toFixed(4) + '°';
|
|
document.getElementById('telAlt').textContent = pos.altitude.toFixed(0) + ' km';
|
|
document.getElementById('telEl').textContent = pos.elevation.toFixed(1) + '°';
|
|
document.getElementById('telAz').textContent = pos.azimuth.toFixed(1) + '°';
|
|
document.getElementById('telDist').textContent = pos.distance.toFixed(0) + ' km';
|
|
|
|
document.getElementById('statVisible').textContent = pos.elevation > 0 ? '1' : '0';
|
|
|
|
if (groundMap) {
|
|
if (satMarker) groundMap.removeLayer(satMarker);
|
|
|
|
const satIcon = L.divIcon({
|
|
className: 'sat-marker-live',
|
|
html: `<div style="width: 20px; height: 20px; background: ${satColor}; border-radius: 50%; border: 3px solid #fff; box-shadow: 0 0 20px ${satColor}, 0 0 40px ${satColor};"></div>`,
|
|
iconSize: [20, 20],
|
|
iconAnchor: [10, 10]
|
|
});
|
|
satMarker = L.marker([pos.lat, pos.lon], { icon: satIcon }).addTo(groundMap);
|
|
}
|
|
|
|
if (pos.track && groundMap) {
|
|
if (orbitTrack) groundMap.removeLayer(orbitTrack);
|
|
|
|
const segments = [];
|
|
let currentSegment = [];
|
|
|
|
for (let i = 0; i < pos.track.length; i++) {
|
|
const p = pos.track[i];
|
|
if (currentSegment.length > 0) {
|
|
const prevLon = currentSegment[currentSegment.length - 1][1];
|
|
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);
|
|
|
|
orbitTrack = L.layerGroup();
|
|
const allOrbitCoords = [];
|
|
segments.forEach(seg => {
|
|
L.polyline(seg, {
|
|
color: satColor,
|
|
weight: 2,
|
|
opacity: 0.6,
|
|
dashArray: '5, 5'
|
|
}).addTo(orbitTrack);
|
|
allOrbitCoords.push(...seg);
|
|
});
|
|
orbitTrack.addTo(groundMap);
|
|
|
|
if (fitBoundsToOrbit && allOrbitCoords.length > 0) {
|
|
allOrbitCoords.push([lat, lon]);
|
|
groundMap.fitBounds(L.latLngBounds(allOrbitCoords), { padding: [30, 30] });
|
|
}
|
|
}
|
|
|
|
if (selectedPass !== null && passes[selectedPass]) {
|
|
drawPolarPlot(passes[selectedPass]);
|
|
drawCurrentPositionOnPolar(pos.azimuth, pos.elevation, satColor);
|
|
} else {
|
|
drawPolarPlotWithPosition(pos.azimuth, pos.elevation, satColor);
|
|
}
|
|
}
|
|
} catch (err) {
|
|
console.error('Position update error:', err);
|
|
}
|
|
}
|
|
|
|
function drawPolarPlotWithPosition(az, el, color) {
|
|
const canvas = document.getElementById('polarPlot');
|
|
const ctx = canvas.getContext('2d');
|
|
const rect = canvas.parentElement.getBoundingClientRect();
|
|
canvas.width = rect.width;
|
|
canvas.height = rect.height - 20;
|
|
|
|
const cx = canvas.width / 2;
|
|
const cy = canvas.height / 2;
|
|
const radius = Math.max(10, Math.min(cx, cy) - 40);
|
|
|
|
ctx.fillStyle = '#0a0a0f';
|
|
ctx.fillRect(0, 0, canvas.width, canvas.height);
|
|
|
|
ctx.strokeStyle = 'rgba(0, 212, 255, 0.15)';
|
|
ctx.lineWidth = 1;
|
|
for (let elRing = 30; elRing <= 90; elRing += 30) {
|
|
const r = radius * (1 - elRing / 90);
|
|
ctx.beginPath();
|
|
ctx.arc(cx, cy, r, 0, Math.PI * 2);
|
|
ctx.stroke();
|
|
|
|
ctx.fillStyle = 'rgba(0, 212, 255, 0.4)';
|
|
ctx.font = '10px Roboto Condensed';
|
|
ctx.fillText(elRing + '°', cx + 5, cy - r + 12);
|
|
}
|
|
|
|
ctx.strokeStyle = 'rgba(0, 212, 255, 0.3)';
|
|
ctx.lineWidth = 2;
|
|
ctx.beginPath();
|
|
ctx.arc(cx, cy, radius, 0, Math.PI * 2);
|
|
ctx.stroke();
|
|
|
|
ctx.strokeStyle = 'rgba(0, 212, 255, 0.1)';
|
|
ctx.lineWidth = 1;
|
|
for (let azLine = 0; azLine < 360; azLine += 45) {
|
|
const angle = (azLine - 90) * Math.PI / 180;
|
|
ctx.beginPath();
|
|
ctx.moveTo(cx, cy);
|
|
ctx.lineTo(cx + radius * Math.cos(angle), cy + radius * Math.sin(angle));
|
|
ctx.stroke();
|
|
}
|
|
|
|
ctx.font = 'bold 14px Orbitron';
|
|
const labels = [
|
|
{ text: 'N', az: 0, color: '#ff4444' },
|
|
{ text: 'E', az: 90, color: '#00d4ff' },
|
|
{ text: 'S', az: 180, color: '#00d4ff' },
|
|
{ text: 'W', az: 270, color: '#00d4ff' }
|
|
];
|
|
labels.forEach(l => {
|
|
const angle = (l.az - 90) * Math.PI / 180;
|
|
const x = cx + (radius + 20) * Math.cos(angle);
|
|
const y = cy + (radius + 20) * Math.sin(angle);
|
|
ctx.fillStyle = l.color;
|
|
ctx.textAlign = 'center';
|
|
ctx.textBaseline = 'middle';
|
|
ctx.fillText(l.text, x, y);
|
|
});
|
|
|
|
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 = '#fff';
|
|
ctx.lineWidth = 3;
|
|
ctx.stroke();
|
|
|
|
ctx.font = 'bold 11px Orbitron';
|
|
ctx.fillStyle = '#fff';
|
|
ctx.textAlign = 'center';
|
|
ctx.fillText(satellites[selectedSatellite]?.name || 'SAT', x, y - 20);
|
|
|
|
ctx.font = '10px Roboto Condensed';
|
|
ctx.fillStyle = el > 0 ? '#00ff88' : '#ff4444';
|
|
ctx.fillText(el.toFixed(1) + '°', x, y + 25);
|
|
} else {
|
|
ctx.font = '12px Rajdhani';
|
|
ctx.fillStyle = '#ff4444';
|
|
ctx.textAlign = 'center';
|
|
ctx.fillText('BELOW HORIZON', cx, cy + radius + 35);
|
|
}
|
|
}
|
|
|
|
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);
|
|
|
|
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 = '#fff';
|
|
ctx.lineWidth = 3;
|
|
ctx.stroke();
|
|
|
|
ctx.font = 'bold 11px Orbitron';
|
|
ctx.fillStyle = '#fff';
|
|
ctx.textAlign = 'center';
|
|
ctx.fillText(satellites[selectedSatellite]?.name || 'SAT', x, y - 20);
|
|
|
|
ctx.font = '10px Roboto Condensed';
|
|
ctx.fillStyle = el > 0 ? '#00ff88' : '#ff4444';
|
|
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') }}"></script>
|
|
<script src="{{ url_for('static', filename='js/core/global-nav.js') }}"></script>
|
|
</body>
|
|
</html>
|