diff --git a/server.js b/server.js
index c8b3256e..84d46519 100644
--- a/server.js
+++ b/server.js
@@ -2067,7 +2067,7 @@ const HELIO_LAYERS = {
'0304': '[SDO,AIA,AIA,304,1,100]',
'0171': '[SDO,AIA,AIA,171,1,100]',
'0094': '[SDO,AIA,AIA,94,1,100]',
- 'HMIIC': '[SDO,HMI,HMI,continuum,1,100]',
+ HMIIC: '[SDO,HMI,HMI,continuum,1,100]',
};
const fetchFromHelioviewer = async (type, timeoutMs = 20000) => {
@@ -7451,6 +7451,7 @@ app.get('/api/wspr/heatmap', async (req, res) => {
// Curated list of active ham radio and amateur-accessible satellites
// Last audited: March 2026
//
+//
// REMOVED (dead/decayed/not ham):
// AO-92 (43137) — re-entered Feb 2024
// PO-101 (43678) — decommissioned, EOL Dec 2025
@@ -7470,260 +7471,21 @@ app.get('/api/wspr/heatmap', async (req, res) => {
//
// FIXED: TEVEL NORAD IDs corrected per AMSAT TLE bulletin
//
-const HAM_SATELLITES = {
- // ── High Priority — Popular FM Satellites ──────────────────────
- ISS: {
- norad: 25544,
- name: 'ISS (ZARYA)',
- color: '#00ffff',
- priority: 1,
- mode: 'FM/APRS/SSTV',
- },
- 'SO-50': {
- norad: 27607,
- name: 'SO-50',
- color: '#00ff00',
- priority: 1,
- mode: 'FM',
- },
- 'AO-91': {
- norad: 43017,
- name: 'AO-91 (Fox-1B)',
- color: '#ff6600',
- priority: 2,
- mode: 'FM (sunlight only)',
- },
- 'AO-123': {
- norad: 61781,
- name: 'AO-123 (ASRTU-1)',
- color: '#ff3399',
- priority: 1,
- mode: 'FM',
- },
- 'SO-124': {
- norad: 62690,
- name: 'SO-124 (HADES-R)',
- color: '#ff44aa',
- priority: 1,
- mode: 'FM',
- },
- 'SO-125': {
- norad: 63492,
- name: 'SO-125 (HADES-ICM)',
- color: '#ff55bb',
- priority: 1,
- mode: 'FM',
- },
- 'QMR-KWT-2': {
- norad: 67291,
- name: 'QMR-KWT-2',
- color: '#ff88dd',
- priority: 1,
- mode: 'FM/SSTV',
- },
-
- // ── Weather Satellites — GOES & METEOR ─────────────────────────
- 'GOES-18': {
- norad: 51850,
- name: 'GOES-18',
- color: '#66ff66',
- priority: 1,
- mode: 'GRB/HRIT/LRIT',
- },
- 'GOES-19': {
- norad: 60133,
- name: 'GOES-19',
- color: '#33cc33',
- priority: 1,
- mode: 'GRB/HRIT/LRIT',
- },
- 'METEOR-M2-3': {
- norad: 57166,
- name: 'METEOR M2-3',
- color: '#FF0000',
- priority: 1,
- mode: 'HRPT/LRPT',
- },
- 'METEOR-M2-4': {
- norad: 59051,
- name: 'METEOR M2-4',
- color: '#FF0000',
- priority: 1,
- mode: 'HRPT/LRPT',
- },
-
- // ── Linear Transponder Satellites ──────────────────────────────
- 'RS-44': {
- norad: 44909,
- name: 'RS-44 (DOSAAF)',
- color: '#ff0066',
- priority: 1,
- mode: 'Linear',
- },
- 'QO-100': {
- norad: 43700,
- name: "QO-100 (Es'hail-2)",
- color: '#ffff00',
- priority: 1,
- mode: 'Linear (GEO)',
- },
- 'AO-7': {
- norad: 7530,
- name: 'AO-7',
- color: '#ffcc00',
- priority: 2,
- mode: 'Linear (daylight)',
- },
- 'FO-29': {
- norad: 24278,
- name: 'FO-29 (JAS-2)',
- color: '#ff6699',
- priority: 2,
- mode: 'Linear (scheduled)',
- },
- 'JO-97': {
- norad: 43803,
- name: 'JO-97 (JY1Sat)',
- color: '#cc99ff',
- priority: 2,
- mode: 'Linear/FM',
- },
- 'AO-73': {
- norad: 39444,
- name: 'AO-73 (FUNcube-1)',
- color: '#ffcc66',
- priority: 2,
- mode: 'Linear/Telemetry',
- },
- 'EO-88': {
- norad: 42017,
- name: 'EO-88 (Nayif-1)',
- color: '#ffaa66',
- priority: 3,
- mode: 'Linear/Telemetry',
- },
-
- // ── CAS (Chinese Amateur Satellites) ───────────────────────────
- 'CAS-4A': {
- norad: 42761,
- name: 'CAS-4A',
- color: '#9966ff',
- priority: 2,
- mode: 'Linear',
- },
- 'CAS-4B': {
- norad: 42759,
- name: 'CAS-4B',
- color: '#9933ff',
- priority: 2,
- mode: 'Linear',
- },
- 'CAS-6': {
- norad: 44881,
- name: 'CAS-6 (TO-108)',
- color: '#cc66ff',
- priority: 2,
- mode: 'Linear',
- },
-
- // ── XW-2 Constellation (CAS-3) — intermittent ─────────────────
- 'XW-2A': {
- norad: 40903,
- name: 'XW-2A (CAS-3A)',
- color: '#66ff99',
- priority: 3,
- mode: 'Linear',
- },
- 'XW-2B': {
- norad: 40911,
- name: 'XW-2B (CAS-3B)',
- color: '#66ffcc',
- priority: 3,
- mode: 'Linear',
- },
- 'XW-2C': {
- norad: 40906,
- name: 'XW-2C (CAS-3C)',
- color: '#99ffcc',
- priority: 3,
- mode: 'Linear',
- },
- 'XW-2F': {
- norad: 40910,
- name: 'XW-2F (CAS-3F)',
- color: '#ccffcc',
- priority: 3,
- mode: 'Linear',
- },
-
- // ── Digipeaters ────────────────────────────────────────────────
- 'IO-117': {
- norad: 53106,
- name: 'IO-117 (GreenCube)',
- color: '#00ff99',
- priority: 2,
- mode: 'Digipeater',
- },
-
- // ── TEVEL Constellation — activated periodically ───────────────
- // NORAD IDs corrected per AMSAT TLE bulletin Dec 2022
- 'TEVEL-1': {
- norad: 51013,
- name: 'TEVEL-1',
- color: '#66ccff',
- priority: 3,
- mode: 'FM',
- },
- 'TEVEL-2': {
- norad: 51069,
- name: 'TEVEL-2',
- color: '#66ddff',
- priority: 3,
- mode: 'FM',
- },
- 'TEVEL-3': {
- norad: 50988,
- name: 'TEVEL-3',
- color: '#66eeff',
- priority: 3,
- mode: 'FM',
- },
- 'TEVEL-4': {
- norad: 51063,
- name: 'TEVEL-4',
- color: '#77ccff',
- priority: 3,
- mode: 'FM',
- },
- 'TEVEL-5': {
- norad: 50998,
- name: 'TEVEL-5',
- color: '#77ddff',
- priority: 3,
- mode: 'FM',
- },
- 'TEVEL-6': {
- norad: 50999,
- name: 'TEVEL-6',
- color: '#77eeff',
- priority: 3,
- mode: 'FM',
- },
- 'TEVEL-7': {
- norad: 51062,
- name: 'TEVEL-7',
- color: '#88ccff',
- priority: 3,
- mode: 'FM',
- },
- 'TEVEL-8': {
- norad: 50989,
- name: 'TEVEL-8',
- color: '#88ddff',
- priority: 3,
- mode: 'FM',
- },
-};
+// UPDATED: Pull curated list of active satellites from external under /src/satellites/satconfig.json"
+const HAM_SATELLITES = (() => {
+ try {
+ // Path relative to server.js location
+ const configPath = path.join(__dirname, 'src', 'satellites', 'satconfig.json');
+ if (fs.existsSync(configPath)) {
+ return JSON.parse(fs.readFileSync(configPath, 'utf8'));
+ }
+ console.warn('[Satellites] satconfig.json not found, using empty registry');
+ return {};
+ } catch (e) {
+ console.error('[Satellites] Error loading satconfig.json:', e.message);
+ return {};
+ }
+})();
let tleCache = { data: null, timestamp: 0 };
const TLE_CACHE_DURATION = 12 * 60 * 60 * 1000; // 12 hours — TLEs don't change that fast
@@ -7732,6 +7494,7 @@ let tleNegativeCache = 0; // Timestamp of last total failure
const TLE_NEGATIVE_TTL = 30 * 60 * 1000; // 30 min backoff after all sources fail
// TLE data sources in priority order — automatic failover
+
const TLE_SOURCES = {
celestrak: {
name: 'CelesTrak',
@@ -7801,7 +7564,11 @@ const TLE_SOURCE_ORDER = (process.env.TLE_SOURCES || 'celestrak,celestrak_legacy
function parseTleText(text, tleData, group) {
// Build NORAD lookup set for fast matching
- const knownNorads = new Set(Object.values(HAM_SATELLITES).map((s) => s.norad));
+ const knownNorads = new Set(
+ Object.values(HAM_SATELLITES)
+ .filter((s) => s && typeof s === 'object' && s.norad)
+ .map((s) => s.norad),
+ );
const lines = text.trim().split('\n');
for (let i = 0; i < lines.length - 2; i += 3) {
@@ -7817,7 +7584,9 @@ function parseTleText(text, tleData, group) {
const alreadyExists = Object.values(tleData).some((sat) => sat.norad === noradId);
if (alreadyExists) continue;
- const hamSat = Object.values(HAM_SATELLITES).find((s) => s.norad === noradId);
+ const hamSat = Object.values(HAM_SATELLITES)
+ .filter((s) => s && typeof s === 'object' && s.norad)
+ .find((s) => s.norad === noradId);
if (hamSat) {
const key = name.replace(/[^A-Z0-9\-]/g, '_').toUpperCase();
tleData[key] = { ...hamSat, tle1: line1, tle2: line2 };
diff --git a/src/plugins/layers/useSatelliteLayer.js b/src/plugins/layers/useSatelliteLayer.js
index db2bd04b..593a7815 100644
--- a/src/plugins/layers/useSatelliteLayer.js
+++ b/src/plugins/layers/useSatelliteLayer.js
@@ -1,28 +1,31 @@
import { useEffect, useRef, useState } from 'react';
import * as satellite from 'satellite.js';
-import { addMinimizeToggle } from './addMinimizeToggle.js';
import { replicatePoint, replicatePath } from '../../utils/geo.js';
-
+import satConfig from '../../satellites/satconfig.json';
export const metadata = {
id: 'satellites',
name: 'Satellite Tracks',
- description: 'Real-time satellite positions with multi-select footprints',
+ description: 'Real-time satellite positions with telemetry and blinking status',
icon: '🛰',
category: 'satellites',
defaultEnabled: true,
defaultOpacity: 1.0,
config: {
- leadTimeMins: 45,
- tailTimeMins: 15,
+ leadTimeMins: 20,
+ tailTimeMins: 45,
showTracks: true,
showFootprints: true,
},
};
-export const useLayer = ({ map, enabled, satellites, setSatellites, opacity, config, allUnits }) => {
+export const useLayer = ({ map, enabled, satellites: propSatellites, setSatellites, opacity, config, allUnits }) => {
const layerGroupRef = useRef(null);
+ const [internalSatellites, setInternalSatellites] = useState([]);
+ // Always render from internalSatellites (has enriched radio/speed data).
+ // If parent passes filtered satellites, use their names to filter ours so filter UI still works.
+ const allowedNames = propSatellites && propSatellites.length > 0 ? new Set(propSatellites.map((s) => s.name)) : null;
+ const satellites = allowedNames ? internalSatellites.filter((s) => allowedNames.has(s.name)) : internalSatellites;
- // 1. Multi-select state (Wipes on browser close)
const [selectedSats, setSelectedSats] = useState(() => {
const saved = sessionStorage.getItem('selected_satellites');
return saved ? JSON.parse(saved) : [];
@@ -30,89 +33,230 @@ export const useLayer = ({ map, enabled, satellites, setSatellites, opacity, con
const [winPos, setWinPos] = useState({ top: 50, right: 10 });
const [winMinimized, setWinMinimized] = useState(false);
- // Sync to session storage
useEffect(() => {
sessionStorage.setItem('selected_satellites', JSON.stringify(selectedSats));
}, [selectedSats]);
- // Helper to add/remove satellites from the active view
const toggleSatellite = (name) => {
setSelectedSats((prev) => (prev.includes(name) ? prev.filter((n) => n !== name) : [...prev, name]));
};
- // Bridge to the popup window HTML
+ const clearAllSats = () => setSelectedSats([]);
+
useEffect(() => {
window.toggleSat = (name) => toggleSatellite(name);
- }, [selectedSats]);
+ window.clearAllSats = () => clearAllSats();
+ return () => {
+ delete window.toggleSat;
+ delete window.clearAllSats;
+ };
+ }, []);
+ useEffect(() => {
+ const styleId = 'sat-layer-ui-styles';
+ if (!document.getElementById(styleId)) {
+ const style = document.createElement('style');
+ style.id = styleId;
+ style.textContent = `
+ @keyframes satGreenBlink { 0% { opacity: 1; color: #00ff00; } 50% { opacity: 0.3; color: #004400; } 100% { opacity: 1; color: #00ff00; } }
+ .sat-visible-blink { animation: satGreenBlink 1s infinite !important; font-weight: bold; text-shadow: 0 0 4px rgba(0,255,0,0.5); }
+
+ .sat-data-window {
+ position: absolute;
+ z-index: 9999 !important;
+ background: rgba(10, 10, 10, 0.45) !important;
+ backdrop-filter: blur(8px);
+ border: 1px solid rgba(0, 255, 255, 0.3);
+ border-radius: 4px;
+ padding: 6px 8px;
+ color: white;
+ font-family: 'JetBrains Mono', monospace;
+ min-width: 140px;
+ max-width: 155px;
+ box-shadow: 0 4px 15px rgba(0, 0, 0, 0.7);
+ pointer-events: auto;
+ }
+
+ .sat-mini-table { width: 100%; border-collapse: collapse; font-size: 10px; margin-top: 4px; }
+ .sat-mini-table td { padding: 1px 0; border-bottom: 1px solid rgba(255,255,255,0.05); }
+ .sat-label-cell { color: #00ffff; }
+ .sat-val { text-align: right; color: #fff; }
+
+ .sat-clear-btn {
+ width: 100%;
+ background: rgba(255, 68, 68, 0.1);
+ border: 1px solid #ff4444;
+ color: #ff4444;
+ cursor: pointer;
+ padding: 4px;
+ font-family: 'JetBrains Mono', monospace;
+ font-size: 9px;
+ font-weight: bold;
+ border-radius: 2px;
+ text-transform: uppercase;
+ margin-top: 5px;
+ }
+ .sat-label { color: #00ffff; font-size: 10px; font-weight: bold; text-shadow: 1px 1px 2px black; white-space: nowrap; margin-top: 2px; }
+ `;
+ document.head.appendChild(style);
+ }
+ return () => document.getElementById(styleId)?.remove();
+ }, []);
const fetchSatellites = async () => {
try {
- const response = await fetch('/api/satellites/tle');
- const data = await response.json();
-
+ const tleResponse = await fetch('/api/satellites/tle');
+ const tleData = await tleResponse.json();
const observerGd = {
latitude: satellite.degreesToRadians(config?.lat || 43.44),
longitude: satellite.degreesToRadians(config?.lon || -88.63),
height: (config?.alt || 260) / 1000,
};
- const satArray = Object.keys(data).map((name) => {
- const satData = data[name];
- let isVisible = false;
- let az = 0,
- el = 0,
- range = 0;
- const leadTrack = [];
-
- if (satData.line1 && satData.line2) {
- const satrec = satellite.twoline2satrec(satData.line1, satData.line2);
- const now = new Date();
- const positionAndVelocity = satellite.propagate(satrec, now);
- const gmst = satellite.gstime(now);
-
- if (positionAndVelocity.position) {
- const positionEcf = satellite.eciToEcf(positionAndVelocity.position, gmst);
- const lookAngles = satellite.ecfToLookAngles(observerGd, positionEcf);
-
- az = lookAngles.azimuth * (180 / Math.PI);
- el = lookAngles.elevation * (180 / Math.PI);
- range = lookAngles.rangeSat;
- isVisible = el > 0;
- }
+ const satArray = Object.keys(tleData)
+ .map((tleName) => {
+ try {
+ const satData = tleData[tleName];
+ const cleanTleName = tleName.trim();
+
+ // 1. NORAD ID — server puts it directly on the object as satData.norad
+ // Fall back to parsing tle1 if needed as server.js outputs
+ const noradInt = satData.norad
+ ? parseInt(satData.norad, 10)
+ : satData.tle1
+ ? parseInt(satData.tle1.substring(2, 7).trim(), 10)
+ : NaN;
+ const noradStr = isNaN(noradInt) ? '' : String(noradInt);
+
+ // 2. Lookup in satconfig.json by NORAD ID
+ let extra = noradStr ? satConfig[noradStr] || null : null;
+
+ // 3. Fallback: name match — skip non-object section header keys
+ if (!extra) {
+ extra =
+ Object.values(satConfig).find(
+ (s) => s && typeof s === 'object' && s.name && s.name.toUpperCase() === cleanTleName.toUpperCase(),
+ ) || null;
+ if (!extra) {
+ console.warn('[SAT] No config match — TLE name: "' + cleanTleName + '", NORAD: ' + noradStr);
+ }
+ extra = extra || {};
+ }
- const minutesToPredict = config?.leadTimeMins || 45;
- for (let i = 0; i <= minutesToPredict; i += 2) {
- const futureTime = new Date(now.getTime() + i * 60000);
- const posVel = satellite.propagate(satrec, futureTime);
- if (posVel.position) {
- const fGmst = satellite.gstime(futureTime);
- const geodetic = satellite.eciToGeodetic(posVel.position, fGmst);
- leadTrack.push([satellite.degreesLat(geodetic.latitude), satellite.degreesLong(geodetic.longitude)]);
+ const displayName = extra.name || cleanTleName;
+ let isVisible = false,
+ az = 0,
+ el = 0,
+ range = 0,
+ alt = 0,
+ lat = 0,
+ lon = 0,
+ speedKmh = 0;
+ const leadTrack = [];
+ const track = [];
+
+ if (satData.tle1 && satData.tle2) {
+ try {
+ const satrec = satellite.twoline2satrec(satData.tle1, satData.tle2);
+ const now = new Date();
+ const pAndV = satellite.propagate(satrec, now);
+ const gmst = satellite.gstime(now);
+
+ if (pAndV.position) {
+ const posGd = satellite.eciToGeodetic(pAndV.position, gmst);
+ const posEcf = satellite.eciToEcf(pAndV.position, gmst);
+ const lookAngles = satellite.ecfToLookAngles(observerGd, posEcf);
+
+ az = satellite.radiansToDegrees(lookAngles.azimuth);
+ el = satellite.radiansToDegrees(lookAngles.elevation);
+ range = lookAngles.rangeSat;
+ isVisible = el > 0;
+
+ // FIX: Use radiansToDegrees (NOT degreesLat/Long)
+ lat = satellite.radiansToDegrees(posGd.latitude);
+ lon = satellite.radiansToDegrees(posGd.longitude);
+ alt = posGd.height;
+
+ // Speed from ECI velocity vector (km/s → km/h)
+ if (pAndV.velocity) {
+ const v = pAndV.velocity;
+ const speedKms = Math.sqrt(v.x * v.x + v.y * v.y + v.z * v.z);
+ speedKmh = speedKms * 3600;
+ }
+ }
+
+ const minutes = config?.leadTimeMins || 20;
+ for (let i = 0; i <= minutes; i += 2) {
+ const futureTime = new Date(now.getTime() + i * 60000);
+ const propagation = satellite.propagate(satrec, futureTime);
+ if (propagation.position) {
+ const geodetic = satellite.eciToGeodetic(propagation.position, satellite.gstime(futureTime));
+ leadTrack.push([
+ satellite.radiansToDegrees(geodetic.latitude),
+ satellite.radiansToDegrees(geodetic.longitude),
+ ]);
+ }
+ }
+ // Tail track — past positions (glowing trail)
+ const tailMins = config?.tailTimeMins || 45;
+ for (let i = tailMins; i >= 0; i -= 2) {
+ const pastTime = new Date(now.getTime() - i * 60000);
+ const propagation = satellite.propagate(satrec, pastTime);
+ if (propagation.position) {
+ const geodetic = satellite.eciToGeodetic(propagation.position, satellite.gstime(pastTime));
+ track.push([
+ satellite.radiansToDegrees(geodetic.latitude),
+ satellite.radiansToDegrees(geodetic.longitude),
+ ]);
+ }
+ }
+ } catch (e) {
+ /* bad TLE — skip silently */
+ }
}
- }
- }
- return {
- ...satData,
- name,
- visible: isVisible,
- azimuth: az,
- elevation: el,
- range: range,
- leadTrack,
- };
- });
+ // Build clean object — explicit fields last so they always win over spreads
+ const satObj = {
+ ...satData,
+ ...extra,
+ name: displayName,
+ // Ensure lat/lon/alt are always numbers
+ lat: typeof lat === 'number' ? lat : 0,
+ lon: typeof lon === 'number' ? lon : 0,
+ alt: typeof alt === 'number' ? alt : 0,
+ visible: isVisible,
+ azimuth: typeof az === 'number' ? az : 0,
+ elevation: typeof el === 'number' ? el : 0,
+ range,
+ speedKmh,
+ leadTrack,
+ track,
+ // Radio fields — always strings, never undefined
+ mode: extra.mode || satData.mode || 'N/A',
+ frequency: String(extra.frequency || satData.frequency || ''),
+ downlink: String(extra.downlink || satData.downlink || ''),
+ uplink: String(extra.uplink || satData.uplink || ''),
+ tone: String(extra.tone || satData.tone || ''),
+ armTone: String(extra.armTone || satData.armTone || ''),
+ hrptFrequency: String(extra.hrptFrequency || satData.hrptFrequency || ''),
+ grbFrequency: String(extra.grbFrequency || satData.grbFrequency || ''),
+ sdFrequency: String(extra.sdFrequency || satData.sdFrequency || ''),
+ };
+ return satObj;
+ } catch (mapErr) {
+ return null;
+ }
+ })
+ .filter(Boolean);
+ setInternalSatellites(satArray);
if (setSatellites) setSatellites(satArray);
} catch (error) {
- console.error('Failed to fetch satellites:', error);
+ console.error('Critical layer error:', error);
}
};
-
const updateInfoWindow = () => {
- const winId = 'sat-data-window';
const container = map.getContainer();
- let win = container.querySelector(`#${winId}`);
+ let win = container.querySelector('#sat-data-window');
if (!selectedSats || selectedSats.length === 0) {
if (win) win.remove();
@@ -121,180 +265,118 @@ export const useLayer = ({ map, enabled, satellites, setSatellites, opacity, con
if (!win) {
win = document.createElement('div');
- win.id = winId;
+ win.id = 'sat-data-window';
win.className = 'sat-data-window leaflet-bar';
- Object.assign(win.style, {
- position: 'absolute',
- width: '260px',
- backgroundColor: 'rgba(0, 15, 15, 0.95)',
- color: '#00ffff',
- borderRadius: '4px',
- border: '1px solid #00ffff',
- zIndex: '1000',
- fontFamily: 'monospace',
- pointerEvents: 'auto',
- boxShadow: '0 0 15px rgba(0,0,0,0.7)',
- cursor: 'default',
- overflow: 'hidden',
- });
container.appendChild(win);
let isDragging = false;
-
- // This panel predates the shared Leaflet control widgets and is positioned
- // relative to the map container using top/right, so it keeps its own drag
- // logic instead of makeDraggable()'s fixed-position viewport model.
win.onmousedown = (e) => {
if (e.button !== 0) return;
- if (!e.target.closest('.sat-data-window-title')) return;
- if (e.target.closest('button')) return;
+ if (!e.target.closest('.sat-drag-handle')) return;
isDragging = true;
win.style.cursor = 'move';
if (map.dragging) map.dragging.disable();
e.preventDefault();
e.stopPropagation();
};
-
window.onmousemove = (e) => {
if (!isDragging) return;
const rect = container.getBoundingClientRect();
- const x = rect.right - e.clientX;
- const y = e.clientY - rect.top;
- win.style.right = `${x - 10}px`;
- win.style.top = `${y - 10}px`;
+ win.style.right = `${rect.right - e.clientX - 10}px`;
+ win.style.top = `${e.clientY - rect.top - 10}px`;
};
-
window.onmouseup = () => {
if (isDragging) {
isDragging = false;
win.style.cursor = 'default';
if (map.dragging) map.dragging.enable();
- setWinPos({
- top: parseInt(win.style.top),
- right: parseInt(win.style.right),
- });
+ setWinPos({ top: parseInt(win.style.top), right: parseInt(win.style.right) });
}
};
}
win.style.top = `${winPos.top}px`;
win.style.right = `${winPos.right}px`;
+ window.__satWinToggleMinimize = () => setWinMinimized((prev) => !prev);
const activeSats = satellites.filter((s) => selectedSats.includes(s.name));
- // Expose minimize toggle so the inline onclick can reach it
- window.__satWinToggleMinimize = () => setWinMinimized((prev) => !prev);
-
- const titleBar = `
-
-
- 🛰 ${activeSats.length} SAT${activeSats.length !== 1 ? 'S' : ''}
-
-
+ let html = `
+
+ 🛰 ${activeSats.length} Tracked Satellites
+
`;
- if (winMinimized) {
- win.style.maxHeight = '';
- win.style.overflowY = 'hidden';
- win.innerHTML = `${titleBar}
`;
- addMinimizeToggle(win, 'sat-data-window', {
- contentClassName: 'sat-data-window-content',
- buttonClassName: 'sat-data-window-minimize',
- getIsMinimized: () => winMinimized,
- onToggle: setWinMinimized,
- persist: false,
- manageButtonEvents: false,
+ if (!winMinimized) {
+ html += `
`;
+ activeSats.forEach((sat) => {
+ const conv = allUnits?.dist === 'imperial' ? 0.621371 : 1;
+ const distUnit = allUnits?.dist === 'imperial' ? ' mi' : ' km';
+
+ // Only render a table row if the value is a non-empty primitive
+ const row = (label, val) => {
+ if (val === null || val === undefined || typeof val === 'function' || typeof val === 'object') return '';
+ const v = String(val).trim();
+ if (!v) return '';
+ return `
| ${label} | ${v} |
`;
+ };
+ // Guard lat/lon in case something slipped through
+ const safeLat = typeof sat.lat === 'number' ? sat.lat : 0;
+ const safeLon = typeof sat.lon === 'number' ? sat.lon : 0;
+ const safeAlt = typeof sat.alt === 'number' ? sat.alt : 0;
+ const safeAz = typeof sat.azimuth === 'number' ? sat.azimuth : 0;
+ const safeEl = typeof sat.elevation === 'number' ? sat.elevation : 0;
+
+ html += `
+
+
+ ${sat.name}
+ ✕
+
+
+ | Lat | ${safeLat.toFixed(2)}° |
+ | Lon | ${safeLon.toFixed(2)}° |
+ | Speed | ${sat.speedKmh ? Math.round(sat.speedKmh).toLocaleString() + ' km/h / ' + Math.round(sat.speedKmh * 0.621371).toLocaleString() + ' mph' : 'N/A'} |
+ | Alt | ${Math.round(safeAlt * conv)}${distUnit} |
+ | Az/El | ${Math.round(safeAz)}° / ${Math.round(safeEl)}° |
+ ${row('Mode', sat.mode)}
+ ${row('Freq', sat.frequency)}
+ ${row('HRPT', sat.hrptFrequency)}
+ ${row('Downlink', sat.downlink)}
+ ${row('Uplink', sat.uplink)}
+ ${row('Tone', sat.tone)}
+ ${row('Arm Tone', sat.armTone)}
+ ${row('GRB Freq', sat.grbFrequency)}
+ ${row('SD Freq', sat.sdFrequency)}
+ | Status |
+ ${sat.visible ? 'VISIBLE' : 'Below Horizon'}
+ |
+
+
+ `;
});
- return;
}
- win.style.maxHeight = 'calc(100% - 80px)';
- win.style.overflowY = 'auto';
-
- const clearAllBtn = `
-
-
- Drag title to move
-
- `;
-
- win.innerHTML =
- titleBar +
- `
` +
- clearAllBtn +
- `
` +
- activeSats
- .map((sat) => {
- const isVisible = sat.visible === true;
- const isImpDist = allUnits.dist === 'imperial';
- const conv = isImpDist ? 0.621371 : 1;
- const distUnit = isImpDist ? ' mi' : ' km';
-
- return `
-
-
- ${sat.name}
-
-
-
- | Az/El: | ${Math.round(sat.azimuth)}° / ${Math.round(sat.elevation)}° |
- | Range: | ${Math.round(sat.range * conv).toLocaleString()}${distUnit} |
- | Mode: | ${sat.mode || 'N/A'} |
- | Status: |
-
- ${isVisible ? 'Visible' : 'Below Horiz'}
- |
-
-
-
- `;
- })
- .join('') +
- `
`;
-
- addMinimizeToggle(win, 'sat-data-window', {
- contentClassName: 'sat-data-window-content',
- buttonClassName: 'sat-data-window-minimize',
- getIsMinimized: () => winMinimized,
- onToggle: setWinMinimized,
- persist: false,
- manageButtonEvents: false,
- });
+ win.innerHTML = html;
};
const renderSatellites = () => {
if (!layerGroupRef.current || !map) return;
layerGroupRef.current.clearLayers();
if (!satellites || satellites.length === 0) return;
-
const globalOpacity = opacity !== undefined ? opacity : 1.0;
satellites.forEach((sat) => {
const isSelected = selectedSats.includes(sat.name);
if (isSelected && config?.showFootprints !== false && sat.alt) {
- const EARTH_RADIUS = 6371;
- const centralAngle = Math.acos(EARTH_RADIUS / (EARTH_RADIUS + sat.alt));
- const footprintRadiusMeters = centralAngle * EARTH_RADIUS * 1000;
+ const R = 6371;
+ const radiusMeters = Math.acos(R / (R + sat.alt)) * R * 1000;
const footColor = sat.visible === true ? '#00ff00' : '#00ffff';
-
replicatePoint(sat.lat, sat.lon).forEach((pos) => {
window.L.circle(pos, {
- radius: footprintRadiusMeters,
+ radius: radiusMeters,
color: footColor,
weight: 2,
opacity: globalOpacity,
@@ -306,8 +388,7 @@ export const useLayer = ({ map, enabled, satellites, setSatellites, opacity, con
}
if (config?.showTracks !== false && sat.track) {
- const pathCoords = sat.track.map((p) => [p[0], p[1]]);
- replicatePath(pathCoords).forEach((coords) => {
+ replicatePath(sat.track.map((p) => [p[0], p[1]])).forEach((coords) => {
if (isSelected) {
for (let i = 0; i < coords.length - 1; i++) {
const fade = i / coords.length;
@@ -338,16 +419,27 @@ export const useLayer = ({ map, enabled, satellites, setSatellites, opacity, con
});
if (isSelected && sat.leadTrack && sat.leadTrack.length > 0) {
- const leadCoords = sat.leadTrack.map((p) => [p[0], p[1]]);
- replicatePath(leadCoords).forEach((lCoords) => {
- window.L.polyline(lCoords, {
- color: '#ffff00',
- weight: 3,
- opacity: 0.8 * globalOpacity,
- dashArray: '8, 12',
- lineCap: 'round',
- interactive: false,
- }).addTo(layerGroupRef.current);
+ replicatePath(sat.leadTrack.map((p) => [p[0], p[1]])).forEach((lCoords) => {
+ for (let i = 0; i < lCoords.length - 1; i++) {
+ // Fade out as we go further into the future (1 = near satellite, 0 = far ahead)
+ const fade = 1 - i / lCoords.length;
+ // Wide fuzzy red glow
+ window.L.polyline([lCoords[i], lCoords[i + 1]], {
+ color: '#ff2200',
+ weight: 6,
+ opacity: fade * 0.25 * globalOpacity,
+ lineCap: 'round',
+ interactive: false,
+ }).addTo(layerGroupRef.current);
+ // Thin bright red core
+ window.L.polyline([lCoords[i], lCoords[i + 1]], {
+ color: '#ff6644',
+ weight: 1.5,
+ opacity: fade * 0.6 * globalOpacity,
+ lineCap: 'round',
+ interactive: false,
+ }).addTo(layerGroupRef.current);
+ }
});
}
}
@@ -365,12 +457,10 @@ export const useLayer = ({ map, enabled, satellites, setSatellites, opacity, con
}),
zIndexOffset: isSelected ? 10000 : 1000,
});
-
marker.on('click', (e) => {
window.L.DomEvent.stopPropagation(e);
toggleSatellite(sat.name);
});
-
marker.addTo(layerGroupRef.current);
});
});
diff --git a/src/satellites/README.md b/src/satellites/README.md
new file mode 100644
index 00000000..1b0f8e00
--- /dev/null
+++ b/src/satellites/README.md
@@ -0,0 +1,112 @@
+# satconfig.json — Satellite Configuration Guide
+
+This file defines all satellites tracked by OpenHamClock. It controls which satellites appear on the map, their display names, colors, and radio frequencies shown in the popup window.
+
+---
+
+## File Structure
+
+The file is a JSON object. Each satellite is keyed by its **NORAD ID** (as a string). Section header keys beginning with `_SECTION_` are used for organization only and are ignored by the application.
+
+```json
+{
+ "_SECTION_1": "── High Priority — Popular FM Satellites ──",
+ "25544": {
+ "norad": 25544,
+ "name": "ISS (ZARYA)",
+ "color": "#00ffff",
+ "priority": 1,
+ "mode": "FM/APRS/SSTV",
+ "downlink": "145.800 MHz",
+ "uplink": "145.990 MHz",
+ "tone": "67.0 Hz"
+ }
+}
+```
+
+---
+
+## Fields
+
+### Required
+
+| Field | Type | Description |
+| ---------- | ------ | -------------------------------------------------------- |
+| `norad` | number | NORAD catalog ID — must match the JSON key exactly |
+| `name` | string | Display name shown on the map and in the popup |
+| `color` | string | Hex color for the satellite marker and track |
+| `priority` | number | `1` = high, `2` = medium, `3` = low (used for filtering) |
+| `mode` | string | Operating mode, e.g. `FM`, `Linear`, `HRIT/LRIT` |
+
+### Optional — Radio Frequencies
+
+Include only the fields that apply to your satellite. Empty or missing fields are automatically hidden in the popup.
+
+| Field | Description | Example |
+| --------------- | --------------------------------------------- | ---------------- |
+| `frequency` | Primary downlink (weather sats, general use) | `"1694.100 MHz"` |
+| `downlink` | FM/Linear downlink frequency or range | `"145.800 MHz"` |
+| `uplink` | FM/Linear uplink frequency or range | `"145.990 MHz"` |
+| `tone` | CTCSS access tone | `"67.0 Hz"` |
+| `armTone` | Arm tone required before access (SO-50 style) | `"74.4 Hz"` |
+| `hrptFrequency` | HRPT downlink for polar weather satellites | `"1700.000 MHz"` |
+| `grbFrequency` | GRB downlink for GOES satellites | `"1686.600 MHz"` |
+| `sdFrequency` | SD downlink for legacy GOES satellites | `"1676.000 MHz"` |
+
+---
+
+## Adding a Satellite
+
+1. Look up the satellite's **NORAD ID** on [Space-Track.org](https://www.space-track.org) or [CelesTrak](https://celestrak.org)
+2. Add a new entry using the NORAD ID as the key
+3. Include only the frequency fields that apply
+4. Place it under the appropriate `_SECTION_` for organization
+
+```json
+"99999": {
+ "norad": 99999,
+ "name": "MY-SAT-1",
+ "color": "#ff9900",
+ "priority": 1,
+ "mode": "FM",
+ "downlink": "436.500 MHz",
+ "uplink": "145.900 MHz",
+ "tone": "67.0 Hz"
+}
+```
+
+> **Important:** The NORAD ID in the JSON key and the `norad` field MUST MATCH EXACTLY.
+
+---
+
+## Removing a Satellite
+
+Delete the entire entry for that NORAD ID. If the satellite's section becomes empty, the `_SECTION_` header can be left in place for future use or removed as well.
+
+---
+
+## Sections
+
+Sections are purely cosmetic — they help organize the file and have no effect on the application. The current sections are:
+
+| Section | Contents |
+| ------------ | -------------------------------- |
+| `_SECTION_1` | High priority FM satellites |
+| `_SECTION_2` | Geostationary weather satellites |
+| `_SECTION_3` | Polar weather satellites |
+| `_SECTION_4` | Linear transponder satellites |
+| `_SECTION_5` | CAS & XW constellations |
+| `_SECTION_6` | Specialized digipeaters |
+| `_SECTION_7` | Reserved for new satellites |
+
+---
+
+## Verifying NORAD IDs
+
+Always verify NORAD IDs before adding a satellite. Common sources:
+
+- [Space-Track.org](https://www.space-track.org) — authoritative source (free account required)
+- [CelesTrak](https://celestrak.org) — no account required
+- [AMSAT Satellite Status](https://www.amsat.org/status/) — for amateur satellites
+
+> **Note:** Some satellites share a launch vehicle with debris objects that have similar NORAD IDs. Always confirm the ID maps to the actual satellite, not a rocket body or debris fragment.
diff --git a/src/satellites/satconfig.json b/src/satellites/satconfig.json
new file mode 100644
index 00000000..8ac8d8a0
--- /dev/null
+++ b/src/satellites/satconfig.json
@@ -0,0 +1,314 @@
+{
+ "_SECTION_1": "── High Priority — Popular FM Satellites ──",
+ "25544": {
+ "norad": 25544,
+ "name": "ISS (ZARYA)",
+ "color": "#00ffff",
+ "priority": 1,
+ "mode": "FM/APRS/SSTV",
+ "downlink": "145.800 MHz",
+ "uplink": "145.990 MHz",
+ "tone": "67.0 Hz"
+ },
+ "27607": {
+ "norad": 27607,
+ "name": "SO-50",
+ "color": "#00ff00",
+ "priority": 1,
+ "mode": "FM",
+ "downlink": "436.795 MHz",
+ "uplink": "145.850 MHz",
+ "tone": "67.0 Hz",
+ "armTone": "74.4 Hz"
+ },
+ "43017": {
+ "norad": 43017,
+ "name": "AO-91 (Fox-1B)",
+ "color": "#ff6600",
+ "priority": 2,
+ "mode": "FM (sunlight only)",
+ "downlink": "145.960 MHz",
+ "uplink": "435.250 MHz",
+ "tone": "67.0 Hz"
+ },
+ "61781": {
+ "norad": 61781,
+ "name": "AO-123 (ASRTU-1)",
+ "color": "#ff3399",
+ "priority": 1,
+ "mode": "FM",
+ "downlink": "435.400 MHz",
+ "uplink": "145.850 MHz",
+ "tone": "67.0 Hz"
+ },
+ "63492": {
+ "norad": 63492,
+ "name": "SO-125 (HADES-ICM)",
+ "color": "#ff55bb",
+ "priority": 1,
+ "mode": "FM",
+ "downlink": "436.666 MHz",
+ "uplink": "145.875 MHz",
+ "tone": "67.0 Hz"
+ },
+ "67291": {
+ "norad": 67291,
+ "name": "QMR-KWT-2",
+ "color": "#ff88dd",
+ "priority": 1,
+ "mode": "FM/SSTV",
+ "downlink": "436.950 MHz",
+ "uplink": "145.920 MHz",
+ "tone": "67.0 Hz"
+ },
+ "_SECTION_2": "── Weather Satellites — Geostationary ──",
+ "51850": {
+ "norad": 51850,
+ "name": "GOES-18 (West)",
+ "color": "#66ff66",
+ "priority": 1,
+ "mode": "GRB/HRIT/LRIT",
+ "frequency": "1694.100 MHz",
+ "grbFrequency": "1686.600 MHz"
+ },
+ "60133": {
+ "norad": 60133,
+ "name": "GOES-19 (East)",
+ "color": "#33cc33",
+ "priority": 1,
+ "mode": "GRB/HRIT/LRIT",
+ "frequency": "1694.100 MHz",
+ "grbFrequency": "1686.600 MHz"
+ },
+ "29155": {
+ "norad": 29155,
+ "name": "EWS-G1 (GOES-13)",
+ "color": "#0066ff",
+ "priority": 2,
+ "mode": "GVAR/SD",
+ "frequency": "1685.700 MHz",
+ "sdFrequency": "1676.000 MHz"
+ },
+ "36411": {
+ "norad": 36411,
+ "name": "EWS-G2 (GOES-15)",
+ "color": "#0044cc",
+ "priority": 2,
+ "mode": "GVAR/SD",
+ "frequency": "1685.700 MHz",
+ "sdFrequency": "1676.000 MHz"
+ },
+ "41105": {
+ "norad": 41105,
+ "name": "ELEKTRO-L2",
+ "color": "#ffcc00",
+ "priority": 2,
+ "mode": "HRIT/LRIT",
+ "frequency": "1691.000 MHz"
+ },
+ "44903": {
+ "norad": 44903,
+ "name": "ELEKTRO-L3",
+ "color": "#ff9900",
+ "priority": 2,
+ "mode": "HRIT/LRIT",
+ "frequency": "1691.000 MHz"
+ },
+ "43823": {
+ "norad": 43823,
+ "name": "GK-2A",
+ "color": "#ff33cc",
+ "priority": 1,
+ "mode": "HRIT/LRIT",
+ "frequency": "1692.140 MHz"
+ },
+ "41836": {
+ "norad": 41836,
+ "name": "HIMAWARI-9",
+ "color": "#9900cc",
+ "priority": 1,
+ "mode": "HimawariCast",
+ "frequency": "4148.000 MHz"
+ },
+ "_SECTION_3": "── Weather Satellites — Polar ──",
+ "57166": {
+ "norad": 57166,
+ "name": "METEOR M2-3",
+ "color": "#FF0000",
+ "priority": 1,
+ "mode": "HRPT/LRPT",
+ "frequency": "137.900 MHz",
+ "hrptFrequency": "1700.000 MHz"
+ },
+ "59051": {
+ "norad": 59051,
+ "name": "METEOR M2-4",
+ "color": "#FF0000",
+ "priority": 1,
+ "mode": "HRPT/LRPT",
+ "frequency": "137.100 MHz",
+ "hrptFrequency": "1700.000 MHz"
+ },
+ "43013": {
+ "norad": 43013,
+ "name": "NOAA-20",
+ "color": "#00ccff",
+ "priority": 2,
+ "mode": "HRD (X-Band)",
+ "frequency": "7812.000 MHz"
+ },
+ "54234": {
+ "norad": 54234,
+ "name": "NOAA-21",
+ "color": "#0099ff",
+ "priority": 2,
+ "mode": "HRD (X-Band)",
+ "frequency": "7812.000 MHz"
+ },
+ "38771": {
+ "norad": 38771,
+ "name": "METOP-B",
+ "color": "#ffff00",
+ "priority": 3,
+ "mode": "AHRPT",
+ "hrptFrequency": "1701.300 MHz"
+ },
+ "43689": {
+ "norad": 43689,
+ "name": "METOP-C",
+ "color": "#ffff33",
+ "priority": 3,
+ "mode": "AHRPT",
+ "hrptFrequency": "1701.300 MHz"
+ },
+ "_SECTION_4": "── Linear Transponder Satellites ──",
+ "44909": {
+ "norad": 44909,
+ "name": "RS-44 (DOSAAF)",
+ "color": "#ff0066",
+ "priority": 1,
+ "mode": "Linear",
+ "downlink": "435.610 - 435.670 MHz",
+ "uplink": "145.935 - 145.995 MHz"
+ },
+ "43700": {
+ "norad": 43700,
+ "name": "QO-100 (Es'hail-2)",
+ "color": "#ffff00",
+ "priority": 1,
+ "mode": "Linear (GEO)",
+ "downlink": "10489.550 - 10489.800 MHz",
+ "uplink": "2400.050 - 2400.300 MHz"
+ },
+ "7530": {
+ "norad": 7530,
+ "name": "AO-7",
+ "color": "#ffcc00",
+ "priority": 2,
+ "mode": "Linear (daylight)",
+ "downlink": "145.925 - 145.975 MHz",
+ "uplink": "432.125 - 432.175 MHz"
+ },
+ "24278": {
+ "norad": 24278,
+ "name": "FO-29 (JAS-2)",
+ "color": "#ff6699",
+ "priority": 2,
+ "mode": "Linear (scheduled)",
+ "downlink": "435.800 - 435.900 MHz",
+ "uplink": "145.900 - 146.000 MHz"
+ },
+ "43803": {
+ "norad": 43803,
+ "name": "JO-97 (JY1Sat)",
+ "color": "#cc99ff",
+ "priority": 2,
+ "mode": "Linear/FM",
+ "downlink": "145.855 - 145.875 MHz",
+ "uplink": "435.100 - 435.120 MHz"
+ },
+ "39444": {
+ "norad": 39444,
+ "name": "AO-73 (FUNcube-1)",
+ "color": "#ffcc66",
+ "priority": 2,
+ "mode": "Linear/Telemetry",
+ "downlink": "145.950 - 145.970 MHz",
+ "uplink": "435.130 - 435.150 MHz"
+ },
+ "_SECTION_5": "── CAS & XW Constellations ──",
+ "42761": {
+ "norad": 42761,
+ "name": "CAS-4A",
+ "color": "#9966ff",
+ "priority": 2,
+ "mode": "Linear",
+ "downlink": "145.910 - 145.930 MHz",
+ "uplink": "435.210 - 435.230 MHz"
+ },
+ "42759": {
+ "norad": 42759,
+ "name": "CAS-4B",
+ "color": "#9933ff",
+ "priority": 2,
+ "mode": "Linear",
+ "downlink": "145.915 - 145.935 MHz",
+ "uplink": "435.270 - 435.290 MHz"
+ },
+ "44881": {
+ "norad": 44881,
+ "name": "CAS-6 (TO-108)",
+ "color": "#cc66ff",
+ "priority": 2,
+ "mode": "Linear",
+ "downlink": "145.915 - 145.935 MHz",
+ "uplink": "435.270 - 435.290 MHz"
+ },
+ "40903": {
+ "norad": 40903,
+ "name": "XW-2A (CAS-3A)",
+ "color": "#66ff99",
+ "priority": 3,
+ "mode": "Linear",
+ "downlink": "145.660 - 145.680 MHz",
+ "uplink": "435.030 - 435.050 MHz"
+ },
+ "40911": {
+ "norad": 40911,
+ "name": "XW-2B (CAS-3B)",
+ "color": "#66ffcc",
+ "priority": 3,
+ "mode": "Linear",
+ "downlink": "145.730 - 145.750 MHz",
+ "uplink": "435.090 - 435.110 MHz"
+ },
+ "40906": {
+ "norad": 40906,
+ "name": "XW-2C (CAS-3C)",
+ "color": "#99ffcc",
+ "priority": 3,
+ "mode": "Linear",
+ "downlink": "145.795 - 145.815 MHz",
+ "uplink": "435.150 - 435.170 MHz"
+ },
+ "40910": {
+ "norad": 40910,
+ "name": "XW-2F (CAS-3F)",
+ "color": "#ccffcc",
+ "priority": 3,
+ "mode": "Linear",
+ "downlink": "145.975 - 145.995 MHz",
+ "uplink": "435.330 - 435.350 MHz"
+ },
+ "_SECTION_6": "── Specialized Digipeaters ──",
+ "53106": {
+ "norad": 53106,
+ "name": "IO-117 (GreenCube)",
+ "color": "#00ff99",
+ "priority": 2,
+ "mode": "Digipeater",
+ "downlink": "435.310 MHz",
+ "uplink": "435.310 MHz"
+ },
+ "_SECTION_7": "── Reserved for New Sats ──"
+}