// satellites.js - Satellite visualization module with real SGP4 positions and animations import * as THREE from 'three'; import { twoline2satrec, sgp4, propagate, degreesToRadians, radiansToDegrees, eciToGeodetic } from 'satellite.js'; import { CONFIG, SATELLITE_CONFIG } from './constants.js'; let satellitePoints = null; let satelliteTrails = null; let satelliteData = []; let showSatellites = false; let showTrails = true; let animationTime = 0; let selectedSatellite = null; let satellitePositions = []; let hoverRingSprite = null; let lockedRingSprite = null; let lockedDotSprite = null; export let breathingPhase = 0; export function updateBreathingPhase() { breathingPhase += SATELLITE_CONFIG.breathingSpeed; } const SATELLITE_API = SATELLITE_CONFIG.apiPath + '?limit=' + SATELLITE_CONFIG.maxCount; const MAX_SATELLITES = SATELLITE_CONFIG.maxCount; const TRAIL_LENGTH = SATELLITE_CONFIG.trailLength; const DOT_TEXTURE_SIZE = 32; function createDotTexture() { const canvas = document.createElement('canvas'); canvas.width = DOT_TEXTURE_SIZE; canvas.height = DOT_TEXTURE_SIZE; const ctx = canvas.getContext('2d'); const center = DOT_TEXTURE_SIZE / 2; const radius = center - 2; const gradient = ctx.createRadialGradient(center, center, 0, center, center, radius); gradient.addColorStop(0, 'rgba(255, 255, 255, 1)'); gradient.addColorStop(0.5, 'rgba(255, 255, 255, 0.8)'); gradient.addColorStop(1, 'rgba(255, 255, 255, 0)'); ctx.fillStyle = gradient; ctx.beginPath(); ctx.arc(center, center, radius, 0, Math.PI * 2); ctx.fill(); const texture = new THREE.CanvasTexture(canvas); texture.needsUpdate = true; return texture; } function createRingTexture(innerRadius, outerRadius, color = '#ffffff') { const size = DOT_TEXTURE_SIZE * 2; const canvas = document.createElement('canvas'); canvas.width = size; canvas.height = size; const ctx = canvas.getContext('2d'); const center = size / 2; ctx.strokeStyle = color; ctx.lineWidth = 3; ctx.beginPath(); ctx.arc(center, center, (innerRadius + outerRadius) / 2, 0, Math.PI * 2); ctx.stroke(); const texture = new THREE.CanvasTexture(canvas); texture.needsUpdate = true; return texture; } export function createSatellites(scene, earthObj) { initSatelliteScene(scene, earthObj); const positions = new Float32Array(MAX_SATELLITES * 3); const colors = new Float32Array(MAX_SATELLITES * 3); const dotTexture = createDotTexture(); const pointsGeometry = new THREE.BufferGeometry(); pointsGeometry.setAttribute('position', new THREE.BufferAttribute(positions, 3)); pointsGeometry.setAttribute('color', new THREE.BufferAttribute(colors, 3)); const pointsMaterial = new THREE.PointsMaterial({ size: SATELLITE_CONFIG.dotSize, map: dotTexture, vertexColors: true, transparent: true, opacity: 0.9, sizeAttenuation: false, alphaTest: 0.1 }); satellitePoints = new THREE.Points(pointsGeometry, pointsMaterial); satellitePoints.visible = false; satellitePoints.userData = { type: 'satellitePoints' }; const originalScale = { x: 1, y: 1, z: 1 }; satellitePoints.onBeforeRender = (renderer, scene, camera, geometry, material) => { if (earthObj && earthObj.scale.x !== 1) { satellitePoints.scale.set( originalScale.x / earthObj.scale.x, originalScale.y / earthObj.scale.y, originalScale.z / earthObj.scale.z ); } else { satellitePoints.scale.set(originalScale.x, originalScale.y, originalScale.z); } }; earthObj.add(satellitePoints); const trailPositions = new Float32Array(MAX_SATELLITES * TRAIL_LENGTH * 3); const trailColors = new Float32Array(MAX_SATELLITES * TRAIL_LENGTH * 3); const trailGeometry = new THREE.BufferGeometry(); trailGeometry.setAttribute('position', new THREE.BufferAttribute(trailPositions, 3)); trailGeometry.setAttribute('color', new THREE.BufferAttribute(trailColors, 3)); const trailMaterial = new THREE.LineBasicMaterial({ vertexColors: true, transparent: true, opacity: 0.3, blending: THREE.AdditiveBlending }); satelliteTrails = new THREE.LineSegments(trailGeometry, trailMaterial); satelliteTrails.visible = false; satelliteTrails.userData = { type: 'satelliteTrails' }; earthObj.add(satelliteTrails); satellitePositions = []; for (let i = 0; i < MAX_SATELLITES; i++) { satellitePositions.push({ current: new THREE.Vector3(), trail: [], trailIndex: 0, trailCount: 0 }); } return satellitePoints; } function computeSatellitePosition(satellite, time) { try { const props = satellite.properties; if (!props || !props.norad_cat_id) { return null; } const noradId = props.norad_cat_id; const inclination = props.inclination || 53; const raan = props.raan || 0; const eccentricity = props.eccentricity || 0.0001; const argOfPerigee = props.arg_of_perigee || 0; const meanAnomaly = props.mean_anomaly || 0; const meanMotion = props.mean_motion || 15; const epoch = props.epoch || ''; // Simplified epoch calculation let epochDate = epoch && epoch.length >= 10 ? new Date(epoch) : time; const epochYear = epochDate.getUTCFullYear() % 100; const startOfYear = new Date(Date.UTC(epochDate.getUTCFullYear(), 0, 1)); const dayOfYear = Math.floor((epochDate - startOfYear) / 86400000) + 1; const msOfDay = epochDate.getUTCHours() * 3600000 + epochDate.getUTCMinutes() * 60000 + epochDate.getUTCSeconds() * 1000 + epochDate.getUTCMilliseconds(); const dayFraction = msOfDay / 86400000; const epochStr = String(epochYear).padStart(2, '0') + String(dayOfYear).padStart(3, '0') + '.' + dayFraction.toFixed(8).substring(2); // Format eccentricity as "0.0001652" (7 chars after decimal) const eccStr = '0' + eccentricity.toFixed(7); const tleLine1 = `1 ${noradId.toString().padStart(5)}U 00001A ${epochStr} .00000000 00000-0 00000-0 0 9999`; const tleLine2 = `2 ${noradId.toString().padStart(5)} ${raan.toFixed(4).padStart(8)} ${inclination.toFixed(4).padStart(8)} ${eccStr.substring(1)} ${argOfPerigee.toFixed(4).padStart(8)} ${meanAnomaly.toFixed(4).padStart(8)} ${meanMotion.toFixed(8).padStart(11)} 0 9999`; const satrec = twoline2satrec(tleLine1, tleLine2); if (!satrec || satrec.error) { return null; } const positionAndVelocity = propagate(satrec, time); if (!positionAndVelocity || !positionAndVelocity.position) { return null; } const x = positionAndVelocity.position.x; const y = positionAndVelocity.position.y; const z = positionAndVelocity.position.z; if (!x || !y || !z) { return null; } const r = Math.sqrt(x * x + y * y + z * z); const earthRadius = 6371; const displayRadius = CONFIG.earthRadius * (earthRadius / 6371) * 1.05; const scale = displayRadius / r; return new THREE.Vector3(x * scale, y * scale, z * scale); } catch (e) { return null; } } function generateFallbackPosition(satellite, index, total) { const radius = CONFIG.earthRadius + 5; const noradId = satellite.properties?.norad_cat_id || index; const inclination = satellite.properties?.inclination || 53; const raan = satellite.properties?.raan || 0; const meanAnomaly = satellite.properties?.mean_anomaly || 0; const hash = String(noradId).split('').reduce((a, b) => a + b.charCodeAt(0), 0); const randomOffset = (hash % 1000) / 1000; const normalizedIndex = index / total; const theta = normalizedIndex * Math.PI * 2 * 10 + (raan * Math.PI / 180); const phi = (inclination * Math.PI / 180) + (meanAnomaly * Math.PI / 180 * 0.1); const adjustedPhi = Math.abs(phi % Math.PI); const adjustedTheta = theta + randomOffset * Math.PI * 2; const x = radius * Math.sin(adjustedPhi) * Math.cos(adjustedTheta); const y = radius * Math.cos(adjustedPhi); const z = radius * Math.sin(adjustedPhi) * Math.sin(adjustedTheta); return new THREE.Vector3(x, y, z); } export async function loadSatellites() { try { const response = await fetch(SATELLITE_API); if (!response.ok) { throw new Error(`HTTP ${response.status}`); } const data = await response.json(); satelliteData = data.features || []; console.log(`Loaded ${satelliteData.length} satellites`); return satelliteData.length; } catch (error) { console.error('Failed to load satellites:', error); return []; } } export function updateSatellitePositions(deltaTime = 0) { if (!satellitePoints || satelliteData.length === 0) return; animationTime += deltaTime * 0.001; const positions = satellitePoints.geometry.attributes.position.array; const colors = satellitePoints.geometry.attributes.color.array; const trailPositions = satelliteTrails.geometry.attributes.position.array; const trailColors = satelliteTrails.geometry.attributes.color.array; const baseTime = new Date(); const count = Math.min(satelliteData.length, MAX_SATELLITES); for (let i = 0; i < count; i++) { const satellite = satelliteData[i]; const props = satellite.properties; const timeOffset = (i / count) * 2 * Math.PI * 0.1; const adjustedTime = new Date(baseTime.getTime() + timeOffset * 1000 * 60 * 10); let pos = computeSatellitePosition(satellite, adjustedTime); if (!pos) { pos = generateFallbackPosition(satellite, i, count); } satellitePositions[i].current.copy(pos); const satPos = satellitePositions[i]; if (i !== window.lockedSatelliteIndex) { satPos.trail[satPos.trailIndex] = pos.clone(); satPos.trailIndex = (satPos.trailIndex + 1) % TRAIL_LENGTH; if (satPos.trailCount < TRAIL_LENGTH) satPos.trailCount++; } positions[i * 3] = pos.x; positions[i * 3 + 1] = pos.y; positions[i * 3 + 2] = pos.z; const inclination = props?.inclination || 53; const name = props?.name || ''; const isStarlink = name.includes('STARLINK'); const isGeo = inclination > 20 && inclination < 30; const isIridium = name.includes('IRIDIUM'); let r, g, b; if (isStarlink) { r = 0.0; g = 0.9; b = 1.0; } else if (isGeo) { r = 1.0; g = 0.8; b = 0.0; } else if (isIridium) { r = 1.0; g = 0.5; b = 0.0; } else if (inclination > 50 && inclination < 70) { r = 0.0; g = 1.0; b = 0.3; } else { r = 1.0; g = 1.0; b = 1.0; } colors[i * 3] = r; colors[i * 3 + 1] = g; colors[i * 3 + 2] = b; const sp = satellitePositions[i]; const trail = sp.trail; const tc = sp.trailCount; const ti = sp.trailIndex; for (let j = 0; j < TRAIL_LENGTH; j++) { const trailIdx = (i * TRAIL_LENGTH + j) * 3; if (j < tc) { const idx = (ti - tc + j + TRAIL_LENGTH) % TRAIL_LENGTH; const t = trail[idx]; if (t) { trailPositions[trailIdx] = t.x; trailPositions[trailIdx + 1] = t.y; trailPositions[trailIdx + 2] = t.z; const alpha = (j + 1) / tc; trailColors[trailIdx] = r * alpha; trailColors[trailIdx + 1] = g * alpha; trailColors[trailIdx + 2] = b * alpha; } else { trailPositions[trailIdx] = pos.x; trailPositions[trailIdx + 1] = pos.y; trailPositions[trailIdx + 2] = pos.z; trailColors[trailIdx] = 0; trailColors[trailIdx + 1] = 0; trailColors[trailIdx + 2] = 0; } } else { trailPositions[trailIdx] = pos.x; trailPositions[trailIdx + 1] = pos.y; trailPositions[trailIdx + 2] = pos.z; trailColors[trailIdx] = 0; trailColors[trailIdx + 1] = 0; trailColors[trailIdx + 2] = 0; } } } for (let i = count; i < MAX_SATELLITES; i++) { positions[i * 3] = 0; positions[i * 3 + 1] = 0; positions[i * 3 + 2] = 0; for (let j = 0; j < TRAIL_LENGTH; j++) { const trailIdx = (i * TRAIL_LENGTH + j) * 3; trailPositions[trailIdx] = 0; trailPositions[trailIdx + 1] = 0; trailPositions[trailIdx + 2] = 0; } } satellitePoints.geometry.attributes.position.needsUpdate = true; satellitePoints.geometry.attributes.color.needsUpdate = true; satellitePoints.geometry.setDrawRange(0, count); satelliteTrails.geometry.attributes.position.needsUpdate = true; satelliteTrails.geometry.attributes.color.needsUpdate = true; } export function toggleSatellites(visible) { showSatellites = visible; if (satellitePoints) { satellitePoints.visible = visible; } if (satelliteTrails) { satelliteTrails.visible = visible && showTrails; } } export function toggleTrails(visible) { showTrails = visible; if (satelliteTrails) { satelliteTrails.visible = visible && showSatellites; } } export function getShowSatellites() { return showSatellites; } export function getSatelliteCount() { return satelliteData.length; } export function getSatelliteAt(index) { if (index >= 0 && index < satelliteData.length) { return satelliteData[index]; } return null; } export function getSatelliteData() { return satelliteData; } export function selectSatellite(index) { selectedSatellite = index; return getSatelliteAt(index); } export function getSatellitePoints() { return satellitePoints; } export function getSatellitePositions() { return satellitePositions; } export function isSatelliteFrontFacing(index, camera) { if (!earthObjRef || !camera) return true; const positions = satellitePositions; if (!positions || !positions[index]) return true; const satPos = positions[index].current; if (!satPos) return true; const toCamera = new THREE.Vector3().subVectors(camera.position, earthObjRef.position).normalize(); const toSat = new THREE.Vector3().subVectors(satPos, earthObjRef.position).normalize(); return toCamera.dot(toSat) < 0; } let earthObjRef = null; let sceneRef = null; export function showHoverRing(position, isLocked = false) { if (!sceneRef || !earthObjRef) return; const ringTexture = createRingTexture(8, 12, isLocked ? '#ffcc00' : '#ffffff'); const spriteMaterial = new THREE.SpriteMaterial({ map: ringTexture, transparent: true, opacity: 0.8, depthTest: false, sizeAttenuation: false }); const ringSize = SATELLITE_CONFIG.ringSize; const sprite = new THREE.Sprite(spriteMaterial); sprite.position.copy(position); const camera = window.camera; const cameraDistance = camera ? camera.position.distanceTo(position) : 400; const scale = ringSize; sprite.scale.set(scale, scale, 1); console.log(`[Ring create] ringSize: ${ringSize}, camDist: ${cameraDistance}, scale: ${scale}`); earthObjRef.add(sprite); if (isLocked) { if (lockedRingSprite) { earthObjRef.remove(lockedRingSprite); } lockedRingSprite = sprite; if (lockedDotSprite) { earthObjRef.remove(lockedDotSprite); } const dotCanvas = createBrighterDotCanvas(); const dotTexture = new THREE.CanvasTexture(dotCanvas); dotTexture.needsUpdate = true; const dotMaterial = new THREE.SpriteMaterial({ map: dotTexture, transparent: true, opacity: 1.0, depthTest: false }); lockedDotSprite = new THREE.Sprite(dotMaterial); lockedDotSprite.position.copy(position); lockedDotSprite.scale.set(4 * cameraDistance / 200, 4 * cameraDistance / 200, 1); earthObjRef.add(lockedDotSprite); } else { if (hoverRingSprite) { earthObjRef.remove(hoverRingSprite); } hoverRingSprite = sprite; } return sprite; } function createBrighterDotCanvas() { const size = DOT_TEXTURE_SIZE * 2; const canvas = document.createElement('canvas'); canvas.width = size; canvas.height = size; const ctx = canvas.getContext('2d'); const center = size / 2; const gradient = ctx.createRadialGradient(center, center, 0, center, center, center); gradient.addColorStop(0, 'rgba(255, 255, 200, 1)'); gradient.addColorStop(0.3, 'rgba(255, 220, 100, 0.9)'); gradient.addColorStop(0.7, 'rgba(255, 180, 50, 0.5)'); gradient.addColorStop(1, 'rgba(255, 150, 0, 0)'); ctx.fillStyle = gradient; ctx.fillRect(0, 0, size, size); return canvas; } export function hideHoverRings() { if (!earthObjRef) return; if (hoverRingSprite) { earthObjRef.remove(hoverRingSprite); hoverRingSprite = null; } } export function hideLockedRing() { if (!earthObjRef) return; if (lockedRingSprite) { earthObjRef.remove(lockedRingSprite); lockedRingSprite = null; } if (lockedDotSprite) { earthObjRef.remove(lockedDotSprite); lockedDotSprite = null; } } export function updateLockedRingPosition(position) { const ringSize = SATELLITE_CONFIG.ringSize; const camera = window.camera; const cameraDistance = camera ? camera.position.distanceTo(position) : 400; if (lockedRingSprite && position) { lockedRingSprite.position.copy(position); const breathScale = 1 + Math.sin(breathingPhase) * SATELLITE_CONFIG.breathingScaleAmplitude; lockedRingSprite.scale.set(ringSize * breathScale, ringSize * breathScale, 1); const breathOpacity = SATELLITE_CONFIG.breathingOpacityMin + Math.sin(breathingPhase) * (SATELLITE_CONFIG.breathingOpacityMax - SATELLITE_CONFIG.breathingOpacityMin); lockedRingSprite.material.opacity = breathOpacity; } if (lockedDotSprite && position) { lockedDotSprite.position.copy(position); const dotBreathScale = 1 + Math.sin(breathingPhase) * SATELLITE_CONFIG.dotBreathingScaleAmplitude; lockedDotSprite.scale.set(4 * cameraDistance / 200 * dotBreathScale, 4 * cameraDistance / 200 * dotBreathScale, 1); lockedDotSprite.material.opacity = SATELLITE_CONFIG.dotOpacityMin + Math.sin(breathingPhase) * (SATELLITE_CONFIG.dotOpacityMax - SATELLITE_CONFIG.dotOpacityMin); } } export function updateHoverRingPosition(position) { const ringSize = SATELLITE_CONFIG.ringSize; const camera = window.camera; const cameraDistance = camera ? camera.position.distanceTo(position) : 400; const scale = ringSize; if (hoverRingSprite && position) { hoverRingSprite.position.copy(position); hoverRingSprite.scale.set(scale, scale, 1); console.log(`[Hover update] ringSize: ${ringSize}, camDist: ${cameraDistance}, scale: ${scale}`); } } export function setSatelliteRingState(index, state, position) { switch (state) { case 'hover': hideHoverRings(); showHoverRing(position, false); break; case 'locked': hideHoverRings(); showHoverRing(position, true); break; case 'none': hideHoverRings(); hideLockedRing(); break; } } export function initSatelliteScene(scene, earth) { sceneRef = scene; earthObjRef = earth; } let predictedOrbitLine = null; function calculateOrbitalPeriod(meanMotion) { return 86400 / meanMotion; } function calculatePredictedOrbit(satellite, periodSeconds, sampleInterval = 10) { const points = []; const samples = Math.ceil(periodSeconds / sampleInterval); const now = new Date(); // Full orbit: from now to now+period (complete circle forward) for (let i = 0; i <= samples; i++) { const time = new Date(now.getTime() + i * sampleInterval * 1000); const pos = computeSatellitePosition(satellite, time); if (pos) points.push(pos); } // If we don't have enough points, use fallback orbit if (points.length < samples * 0.5) { points.length = 0; const radius = CONFIG.earthRadius + 5; const noradId = satellite.properties?.norad_cat_id || 0; const inclination = satellite.properties?.inclination || 53; const raan = satellite.properties?.raan || 0; const meanAnomaly = satellite.properties?.mean_anomaly || 0; for (let i = 0; i <= samples; i++) { const theta = (i / samples) * Math.PI * 2; const phi = (inclination * Math.PI / 180); const x = radius * Math.sin(phi) * Math.cos(theta + raan * Math.PI / 180); const y = radius * Math.cos(phi); const z = radius * Math.sin(phi) * Math.sin(theta + raan * Math.PI / 180); points.push(new THREE.Vector3(x, y, z)); } } return points; } export function showPredictedOrbit(satellite) { hidePredictedOrbit(); const props = satellite.properties; const meanMotion = props?.mean_motion || 15; const periodSeconds = calculateOrbitalPeriod(meanMotion); const points = calculatePredictedOrbit(satellite, periodSeconds); if (points.length < 2) return; const positions = new Float32Array(points.length * 3); const colors = new Float32Array(points.length * 3); for (let i = 0; i < points.length; i++) { positions[i * 3] = points[i].x; positions[i * 3 + 1] = points[i].y; positions[i * 3 + 2] = points[i].z; const t = i / (points.length - 1); colors[i * 3] = 1 - t * 0.4; colors[i * 3 + 1] = 1 - t * 0.6; colors[i * 3 + 2] = t; } const geometry = new THREE.BufferGeometry(); geometry.setAttribute('position', new THREE.BufferAttribute(positions, 3)); geometry.setAttribute('color', new THREE.BufferAttribute(colors, 3)); const material = new THREE.LineBasicMaterial({ vertexColors: true, transparent: true, opacity: 0.8, blending: THREE.AdditiveBlending }); predictedOrbitLine = new THREE.Line(geometry, material); earthObjRef.add(predictedOrbitLine); } export function hidePredictedOrbit() { if (predictedOrbitLine) { earthObjRef.remove(predictedOrbitLine); predictedOrbitLine.geometry.dispose(); predictedOrbitLine.material.dispose(); predictedOrbitLine = null; } }