Files
planet/frontend/public/earth/js/satellites.js
rayd1o bb6b18fe3b feat(earth): satellite dot rendering with hover/lock rings, dim cables when satellite locked
- Change satellite points from squares to circular dots
- Add hover ring (white) and lock ring (yellow) for satellites
- Fix satellite hover/lock ring state management
- Dim all cables when satellite is locked
- Increase MAX_SATELLITES to 2000
- Fix satIntersects scoping bug
2026-03-19 17:41:53 +08:00

457 lines
13 KiB
JavaScript

// 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 } 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;
const SATELLITE_API = '/api/v1/visualization/geo/satellites?limit=2000';
const MAX_SATELLITES = 2000;
const TRAIL_LENGTH = 30;
const DOT_TEXTURE_SIZE = 32;
function createCircularDotTexture() {
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 = createCircularDotTexture();
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: 1.5,
map: dotTexture,
vertexColors: true,
transparent: true,
opacity: 0.9,
sizeAttenuation: true,
alphaTest: 0.1
});
satellitePoints = new THREE.Points(pointsGeometry, pointsMaterial);
satellitePoints.visible = false;
satellitePoints.userData = { type: 'satellitePoints' };
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: []
});
}
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 || '';
const year = epoch && epoch.length >= 4 ? parseInt(epoch.substring(0, 4)) : time.getUTCFullYear();
const month = epoch && epoch.length >= 7 ? parseInt(epoch.substring(5, 7)) : time.getUTCMonth() + 1;
const day = epoch && epoch.length >= 10 ? parseInt(epoch.substring(8, 10)) : time.getUTCDate();
const tleLine1 = `1 ${String(noradId).padStart(5, '0')}U 00001A ${year}${String(month).padStart(2, '0')}${String(day).padStart(2, '0')}.00000000 .00000000 00000-0 00000-0 0 9999`;
const tleLine2 = `2 ${String(noradId).padStart(5, '0')} ${String(raan.toFixed(4)).padStart(8, ' ')} ${String(inclination.toFixed(4)).padStart(8, ' ')} ${String(eccentricity.toFixed(7)).replace('0.', '.')} ${String(argOfPerigee.toFixed(4)).padStart(8, ' ')} ${String(meanAnomaly.toFixed(4)).padStart(8, ' ')} ${String(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;
} 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);
satellitePositions[i].trail.push(pos.clone());
if (satellitePositions[i].trail.length > TRAIL_LENGTH) {
satellitePositions[i].trail.shift();
}
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 trail = satellitePositions[i].trail;
for (let j = 0; j < TRAIL_LENGTH; j++) {
const trailIdx = (i * TRAIL_LENGTH + j) * 3;
if (j < trail.length) {
const t = trail[j];
trailPositions[trailIdx] = t.x;
trailPositions[trailIdx + 1] = t.y;
trailPositions[trailIdx + 2] = t.z;
const alpha = j / trail.length;
trailColors[trailIdx] = r * alpha;
trailColors[trailIdx + 1] = g * alpha;
trailColors[trailIdx + 2] = b * alpha;
} else {
trailPositions[trailIdx] = 0;
trailPositions[trailIdx + 1] = 0;
trailPositions[trailIdx + 2] = 0;
trailColors[trailIdx] = 0;
trailColors[trailIdx + 1] = 0;
trailColors[trailIdx + 2] = 0;
}
}
}
for (let i = count; i < 2000; 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;
}
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
});
const sprite = new THREE.Sprite(spriteMaterial);
sprite.position.copy(position);
sprite.scale.set(3, 3, 1);
earthObjRef.add(sprite);
if (isLocked) {
if (lockedRingSprite) {
earthObjRef.remove(lockedRingSprite);
}
lockedRingSprite = sprite;
} else {
if (hoverRingSprite) {
earthObjRef.remove(hoverRingSprite);
}
hoverRingSprite = sprite;
}
return sprite;
}
export function hideHoverRings() {
if (!earthObjRef) return;
if (hoverRingSprite) {
earthObjRef.remove(hoverRingSprite);
hoverRingSprite = null;
}
}
export function hideLockedRing() {
if (!earthObjRef || !lockedRingSprite) return;
earthObjRef.remove(lockedRingSprite);
lockedRingSprite = null;
}
export function updateLockedRingPosition(position) {
if (lockedRingSprite && position) {
lockedRingSprite.position.copy(position);
}
}
export function updateHoverRingPosition(position) {
if (hoverRingSprite && position) {
hoverRingSprite.position.copy(position);
}
}
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;
}