Files
planet/frontend/public/earth/js/satellites.js
2026-04-20 17:43:59 +08:00

1115 lines
30 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
// satellites.js - Satellite visualization module with real SGP4 positions and animations
import * as THREE from "three";
import { twoline2satrec, propagate } from "satellite.js";
import { CONFIG, SATELLITE_CONFIG } from "./constants.js";
import { latLonToVector3 } from "./utils.js";
let satellitePoints = null;
let satelliteTrails = null;
let satelliteData = [];
let showSatellites = false;
let showTrails = true;
let selectedSatellite = null;
let satellitePositions = [];
let hoverRingSprite = null;
let lockedRingSprite = null;
let lockedDotSprite = null;
let predictedOrbitLine = null;
let relatedSatelliteSprites = [];
let earthObjRef = null;
let sceneRef = null;
let cameraRef = null;
let lockedSatelliteIndex = null;
let hoveredSatelliteIndex = null;
let positionUpdateAccumulator = 0;
let satelliteCapacity = 0;
const TRAIL_LENGTH = SATELLITE_CONFIG.trailLength;
const DOT_TEXTURE_SIZE = 32;
const POSITION_UPDATE_INTERVAL_MS = 250;
const scratchWorldSatellitePosition = new THREE.Vector3();
const scratchToCamera = new THREE.Vector3();
const scratchToSatellite = new THREE.Vector3();
export let breathingPhase = 0;
const SATELLITE_LEGEND_RULES = [
{
key: "equatorial",
label: "赤道轨道0-30°",
color: "#ff3333",
match: (props) => {
const inclination = Number(props?.inclination ?? 0);
return inclination >= 0 && inclination < 30;
},
},
{
key: "low",
label: "低倾角轨道30-60°",
color: "#ff9933",
match: (props) => {
const inclination = Number(props?.inclination ?? 0);
return inclination >= 30 && inclination < 60;
},
},
{
key: "medium",
label: "中倾角轨道60-90°",
color: "#ffff33",
match: (props) => {
const inclination = Number(props?.inclination ?? 0);
return inclination >= 60 && inclination < 90;
},
},
{
key: "high",
label: "高倾角轨道90-120°",
color: "#33ff33",
match: (props) => {
const inclination = Number(props?.inclination ?? 0);
return inclination >= 90 && inclination < 120;
},
},
{
key: "retrograde",
label: "逆行轨道120-180°",
color: "#3333ff",
match: (props) => {
const inclination = Number(props?.inclination ?? 0);
return inclination >= 120 && inclination <= 180;
},
},
{
key: "other",
label: "其他",
color: "#d7e2f4",
match: () => true,
},
];
const SATELLITE_RULE_COLOR_CACHE = new Map();
function getSatelliteLegendRule(props = {}) {
return (
SATELLITE_LEGEND_RULES.find((rule) => rule.match(props)) ||
SATELLITE_LEGEND_RULES[SATELLITE_LEGEND_RULES.length - 1]
);
}
function getSatelliteRuleColor(rule) {
if (!rule) {
return { r: 1, g: 1, b: 1 };
}
if (SATELLITE_RULE_COLOR_CACHE.has(rule.key)) {
return SATELLITE_RULE_COLOR_CACHE.get(rule.key);
}
const color = new THREE.Color(rule.color);
const rgb = { r: color.r, g: color.g, b: color.b };
SATELLITE_RULE_COLOR_CACHE.set(rule.key, rgb);
return rgb;
}
export function updateBreathingPhase(deltaTime = 16) {
breathingPhase += SATELLITE_CONFIG.breathingSpeed * (deltaTime / 16);
}
export function getSatelliteLegendItems() {
const presentKeys = new Set();
satelliteData.forEach((satellite) => {
const props = satellite?.properties || {};
const rule = SATELLITE_LEGEND_RULES.find((item) => item.match(props));
if (rule) {
presentKeys.add(rule.key);
}
});
if (presentKeys.size === 0) {
return SATELLITE_LEGEND_RULES.map(({ label, color }) => ({ label, color }));
}
const items = SATELLITE_LEGEND_RULES
.filter((item) => presentKeys.has(item.key))
.map(({ key, label, color }) => ({ key, label, color }));
return items.map(({ label, color }) => ({ label, color }));
}
export function setSelectedSatelliteLegend(props) {
return getSatelliteLegendRule(props || {});
}
export function clearSelectedSatelliteLegend() {
return null;
}
function disposeMaterial(material) {
if (!material) return;
if (Array.isArray(material)) {
material.forEach(disposeMaterial);
return;
}
if (material.map) {
material.map.dispose();
}
material.dispose();
}
function disposeObject3D(object, parent = earthObjRef) {
if (!object) return;
if (parent) {
parent.remove(object);
} else if (object.parent) {
object.parent.remove(object);
}
if (object.geometry) {
object.geometry.dispose();
}
if (object.material) {
disposeMaterial(object.material);
}
}
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 dotTexture = createDotTexture();
const pointsGeometry = new THREE.BufferGeometry();
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 = () => {
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 trailGeometry = new THREE.BufferGeometry();
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);
ensureSatelliteCapacity(0);
positionUpdateAccumulator = POSITION_UPDATE_INTERVAL_MS;
return satellitePoints;
}
function getRequestedSatelliteLimit() {
return SATELLITE_CONFIG.maxCount < 0 ? null : SATELLITE_CONFIG.maxCount;
}
function createSatellitePositionState() {
return {
current: new THREE.Vector3(),
trail: [],
trailIndex: 0,
trailCount: 0,
};
}
function ensureSatelliteCapacity(count) {
if (!satellitePoints || !satelliteTrails) return;
const nextCapacity = Math.max(count, 0);
if (nextCapacity === satelliteCapacity) return;
const positions = new Float32Array(nextCapacity * 3);
const colors = new Float32Array(nextCapacity * 3);
satellitePoints.geometry.setAttribute(
"position",
new THREE.BufferAttribute(positions, 3),
);
satellitePoints.geometry.setAttribute(
"color",
new THREE.BufferAttribute(colors, 3),
);
satellitePoints.geometry.setDrawRange(0, 0);
const trailPositions = new Float32Array(nextCapacity * TRAIL_LENGTH * 3);
const trailColors = new Float32Array(nextCapacity * TRAIL_LENGTH * 3);
satelliteTrails.geometry.setAttribute(
"position",
new THREE.BufferAttribute(trailPositions, 3),
);
satelliteTrails.geometry.setAttribute(
"color",
new THREE.BufferAttribute(trailColors, 3),
);
satellitePositions = Array.from(
{ length: nextCapacity },
createSatellitePositionState,
);
satelliteCapacity = nextCapacity;
}
function computeSatellitePosition(satellite, time) {
try {
const props = satellite.properties;
if (!props || !props.norad_cat_id) {
return null;
}
const satrec = buildSatrecFromProperties(props, time);
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 (!Number.isFinite(x) || !Number.isFinite(y) || !Number.isFinite(z)) {
return null;
}
const r = Math.sqrt(x * x + y * y + z * z);
const displayRadius = CONFIG.earthRadius * 1.05;
const scale = displayRadius / r;
return new THREE.Vector3(x * scale, y * scale, z * scale);
} catch (error) {
return null;
}
}
function buildSatrecFromProperties(props, fallbackTime) {
if (props.tle_line1 && props.tle_line2) {
// Prefer source-provided TLE lines so the client does not need to rebuild them.
const satrec = twoline2satrec(props.tle_line1, props.tle_line2);
if (!satrec.error) {
return satrec;
}
}
const tleLines = buildTleLinesFromElements(props, fallbackTime);
if (!tleLines) {
return null;
}
return twoline2satrec(tleLines.line1, tleLines.line2);
}
function computeTleChecksum(line) {
let sum = 0;
for (const char of line.slice(0, 68)) {
if (char >= "0" && char <= "9") {
sum += Number(char);
} else if (char === "-") {
sum += 1;
}
}
return String(sum % 10);
}
function buildTleLinesFromElements(props, fallbackTime) {
if (!props?.norad_cat_id) {
return null;
}
const requiredValues = [
props.inclination,
props.raan,
props.eccentricity,
props.arg_of_perigee,
props.mean_anomaly,
props.mean_motion,
];
if (requiredValues.some((value) => value === null || value === undefined)) {
return null;
}
const epochDate =
props.epoch && String(props.epoch).length >= 10
? new Date(props.epoch)
: fallbackTime;
if (Number.isNaN(epochDate.getTime())) {
return null;
}
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).slice(1);
const eccentricityDigits = Math.round(Number(props.eccentricity) * 1e7)
.toString()
.padStart(7, "0");
// Keep a local fallback for historical rows that do not have stored TLE lines yet.
const line1Core = `1 ${String(props.norad_cat_id).padStart(5, "0")}U 00001A ${epochStr} .00000000 00000-0 00000-0 0 999`;
const line2Core = `2 ${String(props.norad_cat_id).padStart(5, "0")} ${Number(
props.inclination,
)
.toFixed(4)
.padStart(
8,
)} ${Number(props.raan).toFixed(4).padStart(8)} ${eccentricityDigits} ${Number(
props.arg_of_perigee,
)
.toFixed(4)
.padStart(8)} ${Number(props.mean_anomaly).toFixed(4).padStart(8)} ${Number(
props.mean_motion,
)
.toFixed(8)
.padStart(11)}00000`;
return {
line1: line1Core + computeTleChecksum(line1Core),
line2: line2Core + computeTleChecksum(line2Core),
};
}
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() {
const limit = getRequestedSatelliteLimit();
const url = new URL(SATELLITE_CONFIG.apiPath, window.location.origin);
if (limit !== null) {
url.searchParams.set("limit", String(limit));
}
const response = await fetch(url.toString());
if (!response.ok) {
throw new Error(`卫星接口返回 HTTP ${response.status}`);
}
const data = await response.json();
satelliteData = data.features || [];
ensureSatelliteCapacity(satelliteData.length);
positionUpdateAccumulator = POSITION_UPDATE_INTERVAL_MS;
return satelliteData.length;
}
export function updateSatellitePositions(deltaTime = 0, force = false) {
if (!satellitePoints || satelliteData.length === 0) return;
const shouldUpdateTrails =
showSatellites || showTrails || lockedSatelliteIndex !== null;
positionUpdateAccumulator += deltaTime;
if (!force && positionUpdateAccumulator < POSITION_UPDATE_INTERVAL_MS) {
return;
}
const elapsedMs = Math.max(
positionUpdateAccumulator,
POSITION_UPDATE_INTERVAL_MS,
);
positionUpdateAccumulator = 0;
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(Date.now() + elapsedMs);
const count = Math.min(satelliteData.length, satelliteCapacity);
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);
if (shouldUpdateTrails && i !== lockedSatelliteIndex) {
const satPos = satellitePositions[i];
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 rule = getSatelliteLegendRule(props);
const { r, g, b } = getSatelliteRuleColor(rule);
colors[i * 3] = r;
colors[i * 3 + 1] = g;
colors[i * 3 + 2] = b;
const satPosition = satellitePositions[i];
for (let j = 0; j < TRAIL_LENGTH; j++) {
const trailIdx = (i * TRAIL_LENGTH + j) * 3;
if (j < satPosition.trailCount) {
const idx =
(satPosition.trailIndex - satPosition.trailCount + j + TRAIL_LENGTH) %
TRAIL_LENGTH;
const trailPoint = satPosition.trail[idx];
if (trailPoint) {
trailPositions[trailIdx] = trailPoint.x;
trailPositions[trailIdx + 1] = trailPoint.y;
trailPositions[trailIdx + 2] = trailPoint.z;
const alpha = (j + 1) / satPosition.trailCount;
trailColors[trailIdx] = r * alpha;
trailColors[trailIdx + 1] = g * alpha;
trailColors[trailIdx + 2] = b * alpha;
continue;
}
}
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 < satelliteCapacity; 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;
// Keep the hover ring synced with the propagated satellite position even
// when the pointer stays still and no new hover event is emitted.
if (
hoveredSatelliteIndex !== null &&
hoveredSatelliteIndex >= 0 &&
hoveredSatelliteIndex < count &&
hoveredSatelliteIndex !== lockedSatelliteIndex
) {
updateHoverRingPosition(satellitePositions[hoveredSatelliteIndex].current);
}
}
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 setSatelliteCamera(camera) {
cameraRef = camera;
}
export function setLockedSatelliteIndex(index) {
lockedSatelliteIndex = index;
}
export function setHoveredSatelliteIndex(index) {
hoveredSatelliteIndex = index;
}
export function isSatelliteFrontFacing(index, camera = cameraRef) {
if (!earthObjRef || !camera) return true;
if (!satellitePositions || !satellitePositions[index]) return true;
const satPos = satellitePositions[index].current;
if (!satPos) return true;
scratchWorldSatellitePosition
.copy(satPos)
.applyMatrix4(earthObjRef.matrixWorld);
scratchToCamera.subVectors(camera.position, earthObjRef.position).normalize();
scratchToSatellite
.subVectors(scratchWorldSatellitePosition, earthObjRef.position)
.normalize();
return scratchToCamera.dot(scratchToSatellite) > 0;
}
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;
}
function createRingSprite(position, isLocked = false) {
if (!earthObjRef) return null;
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 sprite = new THREE.Sprite(spriteMaterial);
sprite.position.copy(position);
sprite.scale.set(SATELLITE_CONFIG.ringSize, SATELLITE_CONFIG.ringSize, 1);
earthObjRef.add(sprite);
return sprite;
}
function createRelatedSatelliteSprite(position, color = "#7dd3fc") {
if (!earthObjRef) return null;
const ringTexture = createRingTexture(7, 11, color);
const spriteMaterial = new THREE.SpriteMaterial({
map: ringTexture,
transparent: true,
opacity: 0.55,
depthTest: false,
sizeAttenuation: false,
});
const sprite = new THREE.Sprite(spriteMaterial);
sprite.position.copy(position);
sprite.scale.set(SATELLITE_CONFIG.ringSize * 0.8, SATELLITE_CONFIG.ringSize * 0.8, 1);
earthObjRef.add(sprite);
return sprite;
}
export function showHoverRing(position, isLocked = false) {
if (!earthObjRef || !position) return null;
if (isLocked) {
hideLockedRing();
lockedRingSprite = createRingSprite(position, true);
const dotCanvas = createBrighterDotCanvas();
const dotTexture = new THREE.CanvasTexture(dotCanvas);
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, 4, 1);
earthObjRef.add(lockedDotSprite);
return lockedRingSprite;
}
hideHoverRings();
hoverRingSprite = createRingSprite(position, false);
return hoverRingSprite;
}
export function hideHoverRings() {
if (hoverRingSprite) {
disposeObject3D(hoverRingSprite);
hoverRingSprite = null;
}
}
export function hideLockedRing() {
if (lockedRingSprite) {
disposeObject3D(lockedRingSprite);
lockedRingSprite = null;
}
if (lockedDotSprite) {
disposeObject3D(lockedDotSprite);
lockedDotSprite = null;
}
}
export function updateLockedRingPosition(position) {
if (!position) return;
if (lockedRingSprite) {
lockedRingSprite.position.copy(position);
const breathScale =
1 + Math.sin(breathingPhase) * SATELLITE_CONFIG.breathingScaleAmplitude;
lockedRingSprite.scale.set(
SATELLITE_CONFIG.ringSize * breathScale,
SATELLITE_CONFIG.ringSize * breathScale,
1,
);
lockedRingSprite.material.opacity =
SATELLITE_CONFIG.breathingOpacityMin +
Math.sin(breathingPhase) *
(SATELLITE_CONFIG.breathingOpacityMax -
SATELLITE_CONFIG.breathingOpacityMin);
}
if (lockedDotSprite) {
lockedDotSprite.position.copy(position);
const dotBreathScale =
1 +
Math.sin(breathingPhase) * SATELLITE_CONFIG.dotBreathingScaleAmplitude;
lockedDotSprite.scale.set(4 * dotBreathScale, 4 * dotBreathScale, 1);
lockedDotSprite.material.opacity =
SATELLITE_CONFIG.dotOpacityMin +
Math.sin(breathingPhase) *
(SATELLITE_CONFIG.dotOpacityMax - SATELLITE_CONFIG.dotOpacityMin);
}
}
export function updateHoverRingPosition(position) {
if (hoverRingSprite && position) {
hoverRingSprite.position.copy(position);
hoverRingSprite.scale.set(
SATELLITE_CONFIG.ringSize,
SATELLITE_CONFIG.ringSize,
1,
);
}
}
export function setSatelliteRingState(index, state, position) {
switch (state) {
case "hover":
hoveredSatelliteIndex = index;
hideHoverRings();
showHoverRing(position, false);
break;
case "locked":
hoveredSatelliteIndex = null;
hideHoverRings();
showHoverRing(position, true);
break;
case "none":
hoveredSatelliteIndex = null;
hideHoverRings();
hideLockedRing();
break;
}
}
export function clearRelatedSatelliteHighlights() {
relatedSatelliteSprites.forEach((item) => {
if (item.sprite) {
disposeObject3D(item.sprite);
}
});
relatedSatelliteSprites = [];
}
export function highlightRelatedSatellites(indices, color = "#7dd3fc") {
clearRelatedSatelliteHighlights();
if (!Array.isArray(indices) || indices.length === 0) return;
indices.forEach((index) => {
const pos = satellitePositions?.[index]?.current;
if (!pos) return;
const sprite = createRelatedSatelliteSprite(pos, color);
if (!sprite) return;
relatedSatelliteSprites.push({ index, sprite, color });
});
}
export function updateRelatedSatelliteHighlights() {
if (relatedSatelliteSprites.length === 0) return;
relatedSatelliteSprites = relatedSatelliteSprites.filter((item) => {
const pos = satellitePositions?.[item.index]?.current;
if (!pos || !item.sprite) return false;
item.sprite.position.copy(pos);
return true;
});
}
export function getRelatedSatelliteIndicesForRegions(
regions,
{ limit = 6, maxAngleDeg = 22 } = {},
) {
if (!Array.isArray(regions) || regions.length === 0 || satellitePositions.length === 0) {
return [];
}
const regionVectors = regions
.filter(
(region) =>
typeof region?.latitude === "number" &&
typeof region?.longitude === "number",
)
.map((region) =>
latLonToVector3(region.latitude, region.longitude, CONFIG.earthRadius + 1)
.clone()
.normalize(),
);
if (regionVectors.length === 0) return [];
const threshold = Math.cos((maxAngleDeg * Math.PI) / 180);
const ranked = [];
satellitePositions.forEach((item, index) => {
const current = item?.current;
if (!current || current.lengthSq() === 0) return;
const satVector = current.clone().normalize();
let bestDot = -1;
regionVectors.forEach((regionVector) => {
bestDot = Math.max(bestDot, satVector.dot(regionVector));
});
if (bestDot >= threshold) {
ranked.push({ index, score: bestDot });
}
});
return ranked
.sort((a, b) => b.score - a.score)
.slice(0, limit)
.map((item) => item.index);
}
export function initSatelliteScene(scene, earth) {
sceneRef = scene;
earthObjRef = earth;
}
function calculateOrbitalPeriod(meanMotion) {
return 86400 / meanMotion;
}
function calculatePredictedOrbit(
satellite,
periodSeconds,
sampleInterval = 10,
) {
const points = [];
const samples = Math.ceil(periodSeconds / sampleInterval);
const now = new Date();
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 (points.length < samples * 0.5) {
points.length = 0;
const radius = CONFIG.earthRadius + 5;
const inclination = satellite.properties?.inclination || 53;
const raan = satellite.properties?.raan || 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();
if (!earthObjRef) return;
const meanMotion = satellite.properties?.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) {
disposeObject3D(predictedOrbitLine);
predictedOrbitLine = null;
}
}
export function clearSatelliteData() {
satelliteData = [];
selectedSatellite = null;
lockedSatelliteIndex = null;
hoveredSatelliteIndex = null;
positionUpdateAccumulator = 0;
satellitePositions.forEach((position) => {
position.current.set(0, 0, 0);
position.trail = [];
position.trailIndex = 0;
position.trailCount = 0;
});
if (satellitePoints) {
const positionAttr = satellitePoints.geometry.attributes.position;
const colorAttr = satellitePoints.geometry.attributes.color;
if (positionAttr?.array) {
positionAttr.array.fill(0);
positionAttr.needsUpdate = true;
}
if (colorAttr?.array) {
colorAttr.array.fill(0);
colorAttr.needsUpdate = true;
}
satellitePoints.geometry.setDrawRange(0, 0);
}
if (satelliteTrails) {
const trailPositionAttr = satelliteTrails.geometry.attributes.position;
const trailColorAttr = satelliteTrails.geometry.attributes.color;
if (trailPositionAttr?.array) {
trailPositionAttr.array.fill(0);
trailPositionAttr.needsUpdate = true;
}
if (trailColorAttr?.array) {
trailColorAttr.array.fill(0);
trailColorAttr.needsUpdate = true;
}
}
hideHoverRings();
hideLockedRing();
hidePredictedOrbit();
clearRelatedSatelliteHighlights();
}
export function resetSatelliteState() {
clearSatelliteData();
if (satellitePoints) {
disposeObject3D(satellitePoints);
satellitePoints = null;
}
if (satelliteTrails) {
disposeObject3D(satelliteTrails);
satelliteTrails = null;
}
satellitePositions = [];
satelliteCapacity = 0;
showSatellites = false;
showTrails = true;
}