Files
planet/frontend/public/earth/js/satellites.js
linkong f14ff6ec0f
Some checks failed
ci / backend (push) Has been cancelled
ci / frontend (push) Has been cancelled
ci / delivery (push) Has been cancelled
release / images (push) Has been cancelled
release: bump version to 0.56.0
2026-05-13 18:21:03 +08:00

2842 lines
86 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, eciToEcf, gstime } from "satellite.js";
import {
CONFIG,
DEFAULT_SATELLITE_DISPLAY_STYLE,
SATELLITE_CONFIG,
SATELLITE_DISPLAY_STYLES,
} from "./constants.js";
import { latLonToVector3 } from "./utils.js";
import {
createIridiumFootprintAdapter,
disposeIridiumFootprintAdapter,
updateIridiumFootprintAdapter,
} from "./iridium-footprint-adapter.js";
let satellitePoints = null;
let satelliteBackdropPoints = 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 lockedHaloMesh = null;
let lockedGroundFootprintMesh = null;
let lockedGroundFootprintFillMesh = null;
let lockedIridiumFootprintMesh = null;
let predictedOrbitLine = null;
let relatedSatelliteSprites = [];
let highlightedSatelliteIndices = null;
let earthObjRef = null;
let sceneRef = null;
let cameraRef = null;
let lockedSatelliteIndex = null;
let hoveredSatelliteIndex = null;
let positionUpdateAccumulator = 0;
let satelliteCapacity = 0;
let satelliteSatrecCache = new Map();
let satelliteDisplayStyle = DEFAULT_SATELLITE_DISPLAY_STYLE;
let satelliteIdleBreathingEnabled = true;
let satelliteRealAltitudeEnabled = true;
const GROUND_FOOTPRINT_RENDER_ORDER = 3;
const SATELLITE_FOOTPRINT_POLICIES = Object.freeze({
NONE: "none",
STARLINK_GROUND_FOOTPRINT: "starlink_ground_footprint",
IRIDIUM_COVERAGE_RING: "iridium_coverage_ring",
});
const SATELLITE_PRESENTATION_MODES = Object.freeze({
SELF_GLOW: "self_glow",
STARLINK_GROUND_FOOTPRINT: "starlink_ground_footprint",
IRIDIUM_SPOT_BEAMS: "iridium_spot_beams",
});
const SATELLITE_CONSTELLATION_LABELS = Object.freeze({
starlink: "Starlink",
"iridium-next": "Iridium NEXT",
"gps-ops": "GPS",
galileo: "Galileo",
glonass: "GLONASS",
beidou: "北斗",
geo: "GEO",
leo: "LEO",
});
const TRAIL_LENGTH = SATELLITE_CONFIG.trailLength;
const TRAIL_RIBBON_VERTEX_SHADER = /* glsl */ `
attribute vec3 instanceStart;
attribute vec3 instanceEnd;
attribute vec3 instanceColorStart;
attribute vec3 instanceColorEnd;
uniform vec2 resolution;
uniform float lineWidth;
varying vec3 vColor;
void main() {
float t = position.x;
float side = position.y;
vec4 clipStart = projectionMatrix * modelViewMatrix * vec4(instanceStart, 1.0);
vec4 clipEnd = projectionMatrix * modelViewMatrix * vec4(instanceEnd, 1.0);
vec2 screenStart = (clipStart.xy / clipStart.w * 0.5 + 0.5) * resolution;
vec2 screenEnd = (clipEnd.xy / clipEnd.w * 0.5 + 0.5) * resolution;
vec2 dir = screenEnd - screenStart;
float segLen = length(dir);
vec4 clipPos = mix(clipStart, clipEnd, t);
if (segLen > 0.001) {
dir /= segLen;
vec2 normal = vec2(-dir.y, dir.x);
clipPos.xy += normal * side * lineWidth * 0.5 / resolution * 2.0 * clipPos.w;
vColor = mix(instanceColorStart, instanceColorEnd, t);
} else {
vColor = vec3(0.0);
}
gl_Position = clipPos;
}
`;
const TRAIL_RIBBON_FRAGMENT_SHADER = /* glsl */ `
varying vec3 vColor;
void main() {
gl_FragColor = vec4(vColor, 1.0);
}
`;
const TRAIL_INSTANCE_ATTRIBUTE_NAMES = [
"instanceStart",
"instanceEnd",
"instanceColorStart",
"instanceColorEnd",
];
const FALLBACK_ORBIT_DAY_MS = 24 * 60 * 60 * 1000;
const FALLBACK_MIN_MEAN_MOTION = 12;
const FALLBACK_MEAN_MOTION_SPREAD = 4;
const FALLBACK_TRAIL_TIP_LENGTH = 0.004;
const FALLBACK_TRAIL_ALPHA_START = 0.2;
const FALLBACK_TRAIL_ALPHA_END = 0.8;
const DOT_TEXTURE_SIZE = 32;
const POSITION_UPDATE_INTERVAL_MS = 250;
const FOOTPRINT_DIRECTION_SAMPLE_MS = 30000;
const BACKGROUND_TRAIL_RESET_DELTA_MS = 2000;
const SATELLITE_TWINKLE_SECONDARY_SPEED = 1.73;
const SATELLITE_TWINKLE_SECONDARY_WEIGHT = 0.28;
const DIMMED_SATELLITE_BRIGHTNESS = 0.42;
const DIMMED_SATELLITE_TRAIL_BRIGHTNESS = 0.24;
const DIMMED_SATELLITE_POINT_OPACITY = 0.62;
const DIMMED_SATELLITE_BACKDROP_OPACITY = 0.1;
const LOCKED_HALO_CORE_RADIUS = 1;
const LOCKED_HALO_CORE_SEGMENTS = 48;
const LOCKED_HALO_RADIUS = 1;
const LOCKED_HALO_BASE_OPACITY = 0.54;
const LOCKED_HALO_OFFSET = 0.0014;
const LOCKED_HALO_CORE_PIXEL_RADIUS = 8;
const LOCKED_HALO_PIXEL_RADIUS = 24;
const FILLED_TEXTURE_RADIUS_RATIO = 0.28;
const LOCKED_RING_IDLE_SCALE = 0.68;
const LOCKED_RING_HOVER_SCALE = 1.32;
const LOCKED_RING_HOVER_LINE_WIDTH = 5;
const HOVER_RING_LINE_WIDTH = 3;
const LOCKED_RING_IDLE_OPACITY = 0.92;
const EARTH_RADIUS_KM = 6378.137;
const GROUND_FOOTPRINT_MIN_ELEVATION_DEG = 25;
const GROUND_FOOTPRINT_RADIUS_OFFSET = 0.72;
const GROUND_FOOTPRINT_DEFAULT_ALTITUDE_KM = 550;
const GROUND_FOOTPRINT_GRID_X = 260;
const GROUND_FOOTPRINT_GRID_Y = 170;
const GROUND_FOOTPRINT_SURFACE_SCALE = 1.003;
const GROUND_FOOTPRINT_SERVICE_RADIUS_FACTOR = 0.5;
const GROUND_FOOTPRINT_LOW_LAT_AXIS_RATIO = 1.18;
const GROUND_FOOTPRINT_HIGH_LAT_AXIS_RATIO = 1.04;
const GROUND_FOOTPRINT_LATITUDE_BLEND_DEG = 65;
const GROUND_FOOTPRINT_GAP_CENTER_MIN_RATIO = 0.04;
const GROUND_FOOTPRINT_GAP_CENTER_MAX_RATIO = 0.82;
const GROUND_FOOTPRINT_GAP_WIDTH_CENTER_KM = 60;
const GROUND_FOOTPRINT_GAP_WIDTH_EDGE_KM = 120;
const GROUND_FOOTPRINT_GAP_LENGTH_RATIO = 1.08;
const GROUND_FOOTPRINT_REBUILD_DISTANCE = 0.001;
const GROUND_FOOTPRINT_REBUILD_DISTANCE_SQ =
GROUND_FOOTPRINT_REBUILD_DISTANCE * GROUND_FOOTPRINT_REBUILD_DISTANCE;
const scratchWorldSatellitePosition = new THREE.Vector3();
const scratchToCamera = new THREE.Vector3();
const scratchToSatellite = new THREE.Vector3();
const scratchFootprintTrack = new THREE.Vector3();
const scratchFootprintLateral = new THREE.Vector3();
const scratchFootprintReference = new THREE.Vector3();
const scratchFootprintVelocity = new THREE.Vector3();
const scratchFootprintTangent = new THREE.Vector3();
const scratchLastGroundFootprintPosition = new THREE.Vector3();
const satelliteSunDirection = new THREE.Vector3(1, 0.2, 0.4).normalize();
let hasGroundFootprintGeometry = false;
export let breathingPhase = 0;
function getPointPixelRatio() {
return typeof window !== "undefined" ? window.devicePixelRatio || 1 : 1;
}
function getSatelliteDotBaseSize() {
return SATELLITE_CONFIG.dotBaseSize;
}
function getSatelliteDotBackdropSize() {
return getSatelliteDotBaseSize() * SATELLITE_CONFIG.dotBackdropScale;
}
function getSatelliteDotZoomScale(cameraDistance) {
return Math.pow(CONFIG.defaultCameraZ / Math.max(cameraDistance, 1), SATELLITE_CONFIG.dotZoomScalePower);
}
function createSatellitePointMaterial({
texture,
size,
opacity = 1,
useVertexColor = true,
baseColor = 0xffffff,
alphaTest = 0.04,
}) {
return new THREE.ShaderMaterial({
uniforms: {
pointTexture: { value: texture },
size: { value: size * getPointPixelRatio() },
opacity: { value: opacity },
baseColor: { value: new THREE.Color(baseColor) },
},
transparent: true,
depthTest: true,
depthWrite: false,
vertexShader: `
uniform float size;
uniform vec3 baseColor;
attribute float alpha;
${useVertexColor ? "attribute vec3 color;" : ""}
varying float vAlpha;
varying vec3 vColor;
void main() {
vAlpha = alpha;
vColor = ${useVertexColor ? "color" : "baseColor"};
gl_PointSize = size;
gl_Position = projectionMatrix * modelViewMatrix * vec4(position, 1.0);
}
`,
fragmentShader: `
uniform sampler2D pointTexture;
uniform float opacity;
varying float vAlpha;
varying vec3 vColor;
void main() {
vec4 texel = texture2D(pointTexture, gl_PointCoord);
float alphaValue = texel.a * opacity * vAlpha;
if (alphaValue < ${alphaTest.toFixed(3)}) discard;
gl_FragColor = vec4(vColor * texel.rgb, alphaValue);
}
`,
});
}
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 getSatelliteIdleBreathingEnabled() {
return satelliteIdleBreathingEnabled;
}
export function setSatelliteIdleBreathingEnabled(enabled) {
satelliteIdleBreathingEnabled = Boolean(enabled);
updateSatelliteIdleBreathingVisual(false);
return satelliteIdleBreathingEnabled;
}
export function getSatelliteRealAltitudeEnabled() {
return satelliteRealAltitudeEnabled;
}
export function setSatelliteRealAltitudeEnabled(enabled) {
const nextEnabled = Boolean(enabled);
if (nextEnabled === satelliteRealAltitudeEnabled) {
return satelliteRealAltitudeEnabled;
}
satelliteRealAltitudeEnabled = nextEnabled;
clearSatelliteTrails();
updateSatellitePositions(0, true, { resetTrails: true });
if (
lockedSatelliteIndex !== null &&
satellitePositions?.[lockedSatelliteIndex]?.current
) {
showHoverRing(satellitePositions[lockedSatelliteIndex].current, true);
}
return satelliteRealAltitudeEnabled;
}
export function updateSatelliteIdleBreathingVisual(isIdle = true) {
if (!satellitePoints || !satelliteBackdropPoints) return;
if (satellitePoints.material.uniforms?.opacity) {
satellitePoints.material.uniforms.opacity.value = 0.9;
} else {
satellitePoints.material.opacity = 0.9;
}
if (satelliteBackdropPoints.material.uniforms?.opacity) {
satelliteBackdropPoints.material.uniforms.opacity.value = 0.42;
} else {
satelliteBackdropPoints.material.opacity = 0.42;
}
const pointAlphaAttr = satellitePoints.geometry.attributes.alpha;
const backdropAlphaAttr = satelliteBackdropPoints.geometry.attributes.alpha;
if (!pointAlphaAttr?.array || !backdropAlphaAttr?.array) return;
const drawCount = satellitePoints.geometry.drawRange?.count ?? satelliteCapacity;
const count = Math.min(drawCount, satelliteCapacity, satellitePositions.length);
for (let i = 0; i < count; i += 1) {
const alpha = shouldHideSatellitePoint(i)
? 0
: getSatelliteTwinkleAlpha(i, isIdle);
pointAlphaAttr.array[i] = alpha;
backdropAlphaAttr.array[i] = alpha;
}
pointAlphaAttr.needsUpdate = true;
backdropAlphaAttr.needsUpdate = true;
}
export function updateSatellitePointSize() {
if (!satellitePoints || !cameraRef) return;
const camDist = cameraRef.position.length();
const zoomScale = getSatelliteDotZoomScale(camDist);
const pointPixelRatio = getPointPixelRatio();
const dotSize = getSatelliteDotBaseSize() * zoomScale;
const backdropSize = getSatelliteDotBackdropSize() * zoomScale;
const shaderDotSize = dotSize * pointPixelRatio;
const shaderBackdropSize = backdropSize * pointPixelRatio;
if (satellitePoints.material.uniforms?.size) {
satellitePoints.material.uniforms.size.value = shaderDotSize;
} else {
satellitePoints.material.size = dotSize;
}
if (satelliteBackdropPoints.material.uniforms?.size) {
satelliteBackdropPoints.material.uniforms.size.value = shaderBackdropSize;
} else {
satelliteBackdropPoints.material.size = backdropSize;
}
}
function getBreathingPulse(phase) {
return 0.5 + 0.5 * Math.sin(phase);
}
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 disposeObjectTree(object, parent = earthObjRef) {
if (!object) return;
object.traverse((child) => {
if (child === object) return;
if (child.geometry) {
child.geometry.dispose();
}
if (child.material) {
disposeMaterial(child.material);
}
});
disposeObject3D(object, parent);
}
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 createBackdropDotTexture() {
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 - 1;
const gradient = ctx.createRadialGradient(
center,
center,
0,
center,
center,
radius,
);
gradient.addColorStop(0, "rgba(7, 14, 27, 0.98)");
gradient.addColorStop(0.55, "rgba(7, 14, 27, 0.88)");
gradient.addColorStop(0.85, "rgba(7, 14, 27, 0.34)");
gradient.addColorStop(1, "rgba(7, 14, 27, 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", lineWidth = 3) {
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 = lineWidth;
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;
}
function createFilledCircleTexture(color = "#ffcc00") {
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 radius = size * FILLED_TEXTURE_RADIUS_RATIO;
ctx.fillStyle = color;
ctx.beginPath();
ctx.arc(center, center, radius, 0, Math.PI * 2);
ctx.fill();
const texture = new THREE.CanvasTexture(canvas);
texture.needsUpdate = true;
return texture;
}
export function createSatellites(scene, earthObj) {
initSatelliteScene(scene, earthObj);
const dotTexture = createDotTexture();
const backdropTexture = createBackdropDotTexture();
const pointsGeometry = new THREE.BufferGeometry();
const backdropGeometry = new THREE.BufferGeometry();
const backdropMaterial = createSatellitePointMaterial({
size: getSatelliteDotBackdropSize(),
texture: backdropTexture,
useVertexColor: false,
baseColor: 0x0b1626,
opacity: 0.42,
alphaTest: 0.04,
});
const pointsMaterial = createSatellitePointMaterial({
size: getSatelliteDotBaseSize(),
texture: dotTexture,
useVertexColor: true,
opacity: 0.9,
alphaTest: 0.1,
});
satelliteBackdropPoints = new THREE.Points(backdropGeometry, backdropMaterial);
satelliteBackdropPoints.visible = false;
satelliteBackdropPoints.userData = { type: "satelliteBackdropPoints" };
satelliteBackdropPoints.renderOrder = 5;
satellitePoints = new THREE.Points(pointsGeometry, pointsMaterial);
satellitePoints.visible = false;
satellitePoints.userData = { type: "satellitePoints" };
satellitePoints.renderOrder = 6;
const originalScale = { x: 1, y: 1, z: 1 };
const syncPointScale = () => {
if (earthObj && earthObj.scale.x !== 1) {
const scaleX = originalScale.x / earthObj.scale.x;
const scaleY = originalScale.y / earthObj.scale.y;
const scaleZ = originalScale.z / earthObj.scale.z;
satellitePoints.scale.set(scaleX, scaleY, scaleZ);
if (satelliteBackdropPoints) {
satelliteBackdropPoints.scale.set(scaleX, scaleY, scaleZ);
}
} else {
satellitePoints.scale.set(originalScale.x, originalScale.y, originalScale.z);
if (satelliteBackdropPoints) {
satelliteBackdropPoints.scale.set(
originalScale.x,
originalScale.y,
originalScale.z,
);
}
}
};
satelliteBackdropPoints.onBeforeRender = syncPointScale;
satellitePoints.onBeforeRender = syncPointScale;
earthObj.add(satelliteBackdropPoints);
earthObj.add(satellitePoints);
// Instanced screen-space ribbon: one quad instance per trail segment.
// Single mesh / single draw call for all satellite trails.
const ribbonGeometry = new THREE.InstancedBufferGeometry();
// Base quad: position.x = t (0=seg-start, 1=seg-end), position.y = side (-1/+1)
ribbonGeometry.setAttribute(
"position",
new THREE.BufferAttribute(new Float32Array([0, -1, 0, 0, 1, 0, 1, -1, 0, 1, 1, 0]), 3),
);
ribbonGeometry.setIndex(new THREE.BufferAttribute(new Uint16Array([0, 2, 1, 2, 3, 1]), 1));
const trailResolution = new THREE.Vector2(window.innerWidth, window.innerHeight);
const trailMaterial = new THREE.ShaderMaterial({
uniforms: {
lineWidth: { value: SATELLITE_CONFIG.trailLineWidth },
resolution: { value: trailResolution },
},
vertexShader: TRAIL_RIBBON_VERTEX_SHADER,
fragmentShader: TRAIL_RIBBON_FRAGMENT_SHADER,
transparent: true,
blending: THREE.AdditiveBlending,
depthWrite: false,
});
satelliteTrails = new THREE.Mesh(ribbonGeometry, trailMaterial);
satelliteTrails.onBeforeRender = (renderer) => renderer.getSize(trailResolution);
satelliteTrails.frustumCulled = false;
satelliteTrails.visible = false;
satelliteTrails.userData = { type: "satelliteTrails" };
earthObj.add(satelliteTrails);
ensureSatelliteCapacity(0);
positionUpdateAccumulator = POSITION_UPDATE_INTERVAL_MS;
return satellitePoints;
}
function getRequestedSatelliteLimit(limitOverride) {
if (limitOverride === null) return null;
if (Number.isFinite(limitOverride) && limitOverride > 0) {
return Math.floor(limitOverride);
}
return SATELLITE_CONFIG.maxCount < 0 ? null : SATELLITE_CONFIG.maxCount;
}
function hashSatelliteUnit(index, salt = 0) {
const value = Math.sin((index + 1) * 12.9898 + salt * 78.233) * 43758.5453;
return value - Math.floor(value);
}
function createSatelliteTwinkleState(index) {
return {
phase: hashSatelliteUnit(index, 1) * Math.PI * 2,
secondaryPhase: hashSatelliteUnit(index, 2) * Math.PI * 2,
speed: 0.38 + hashSatelliteUnit(index, 3) * 0.72,
floor: hashSatelliteUnit(index, 4) * 0.16,
intensity: 0.68 + hashSatelliteUnit(index, 5) * 0.62,
};
}
function createSatellitePositionState(index = 0) {
return {
current: new THREE.Vector3(),
trail: [],
trailIndex: 0,
trailCount: 0,
twinkle: createSatelliteTwinkleState(index),
};
}
function getSatelliteTwinkleAlpha(index, isIdle = true) {
if (!satelliteIdleBreathingEnabled || !isIdle) return 1;
const twinkle = satellitePositions[index]?.twinkle || createSatelliteTwinkleState(index);
const primaryPulse = getBreathingPulse(
breathingPhase * twinkle.speed + twinkle.phase,
);
const secondaryPulse = getBreathingPulse(
breathingPhase * twinkle.speed * SATELLITE_TWINKLE_SECONDARY_SPEED +
twinkle.secondaryPhase,
);
const mixedPulse = THREE.MathUtils.clamp(
primaryPulse * (1 - SATELLITE_TWINKLE_SECONDARY_WEIGHT) +
secondaryPulse * SATELLITE_TWINKLE_SECONDARY_WEIGHT,
0,
1,
);
const shapedPulse = twinkle.floor + Math.pow(mixedPulse, 1.8) * twinkle.intensity;
return THREE.MathUtils.clamp(
SATELLITE_CONFIG.dotOpacityMin +
shapedPulse * (SATELLITE_CONFIG.dotOpacityMax - SATELLITE_CONFIG.dotOpacityMin),
SATELLITE_CONFIG.dotOpacityMin,
SATELLITE_CONFIG.dotOpacityMax,
);
}
function resetSatelliteTrailState() {
satellitePositions.forEach((position) => {
position.trail = [];
position.trailIndex = 0;
position.trailCount = 0;
});
}
function clearSatelliteTrailGeometry() {
if (!satelliteTrails) return;
for (const name of TRAIL_INSTANCE_ATTRIBUTE_NAMES) {
const attr = satelliteTrails.geometry.attributes[name];
if (attr?.array) {
attr.array.fill(0);
attr.needsUpdate = true;
}
}
}
function clearSatelliteTrails() {
resetSatelliteTrailState();
clearSatelliteTrailGeometry();
}
function ensureSatelliteCapacity(count) {
if (!satellitePoints || !satelliteBackdropPoints || !satelliteTrails) return;
const nextCapacity = Math.max(count, 0);
if (nextCapacity === satelliteCapacity) return;
const previousPointPositions =
satellitePoints.geometry.attributes.position?.array || null;
const previousBackdropPositions =
satelliteBackdropPoints.geometry.attributes.position?.array || null;
const previousColors = satellitePoints.geometry.attributes.color?.array || null;
const previousPointAlphas = satellitePoints.geometry.attributes.alpha?.array || null;
const previousBackdropAlphas =
satelliteBackdropPoints.geometry.attributes.alpha?.array || null;
const previousInstanceStarts =
satelliteTrails.geometry.attributes.instanceStart?.array || null;
const previousInstanceEnds =
satelliteTrails.geometry.attributes.instanceEnd?.array || null;
const previousInstanceColorStarts =
satelliteTrails.geometry.attributes.instanceColorStart?.array || null;
const previousInstanceColorEnds =
satelliteTrails.geometry.attributes.instanceColorEnd?.array || null;
const previousSatellitePositions = satellitePositions;
const previousCapacity = satelliteCapacity;
const positions = new Float32Array(nextCapacity * 3);
const backdropPositions = new Float32Array(nextCapacity * 3);
const colors = new Float32Array(nextCapacity * 3);
const pointAlphas = new Float32Array(nextCapacity);
const backdropAlphas = new Float32Array(nextCapacity);
pointAlphas.fill(1);
backdropAlphas.fill(1);
if (previousPointPositions) {
positions.set(
previousPointPositions.subarray(0, Math.min(previousPointPositions.length, positions.length)),
);
}
if (previousBackdropPositions) {
backdropPositions.set(
previousBackdropPositions.subarray(
0,
Math.min(previousBackdropPositions.length, backdropPositions.length),
),
);
}
if (previousColors) {
colors.set(previousColors.subarray(0, Math.min(previousColors.length, colors.length)));
}
if (previousPointAlphas) {
pointAlphas.set(
previousPointAlphas.subarray(0, Math.min(previousPointAlphas.length, pointAlphas.length)),
);
}
if (previousBackdropAlphas) {
backdropAlphas.set(
previousBackdropAlphas.subarray(
0,
Math.min(previousBackdropAlphas.length, backdropAlphas.length),
),
);
}
satelliteBackdropPoints.geometry.setAttribute(
"position",
new THREE.BufferAttribute(backdropPositions, 3),
);
satelliteBackdropPoints.geometry.setAttribute(
"alpha",
new THREE.BufferAttribute(backdropAlphas, 1),
);
satelliteBackdropPoints.geometry.setDrawRange(
0,
Math.min(previousCapacity, nextCapacity),
);
satellitePoints.geometry.setAttribute(
"position",
new THREE.BufferAttribute(positions, 3),
);
satellitePoints.geometry.setAttribute(
"color",
new THREE.BufferAttribute(colors, 3),
);
satellitePoints.geometry.setAttribute(
"alpha",
new THREE.BufferAttribute(pointAlphas, 1),
);
satellitePoints.geometry.setDrawRange(0, Math.min(previousCapacity, nextCapacity));
const segCount = nextCapacity * (TRAIL_LENGTH - 1);
const instanceStarts = new Float32Array(segCount * 3);
const instanceEnds = new Float32Array(segCount * 3);
const instanceColorStarts = new Float32Array(segCount * 3);
const instanceColorEnds = new Float32Array(segCount * 3);
if (previousInstanceStarts) {
instanceStarts.set(
previousInstanceStarts.subarray(0, Math.min(previousInstanceStarts.length, instanceStarts.length)),
);
}
if (previousInstanceEnds) {
instanceEnds.set(
previousInstanceEnds.subarray(0, Math.min(previousInstanceEnds.length, instanceEnds.length)),
);
}
if (previousInstanceColorStarts) {
instanceColorStarts.set(
previousInstanceColorStarts.subarray(0, Math.min(previousInstanceColorStarts.length, instanceColorStarts.length)),
);
}
if (previousInstanceColorEnds) {
instanceColorEnds.set(
previousInstanceColorEnds.subarray(0, Math.min(previousInstanceColorEnds.length, instanceColorEnds.length)),
);
}
satelliteTrails.geometry.setAttribute(
"instanceStart",
new THREE.InstancedBufferAttribute(instanceStarts, 3),
);
satelliteTrails.geometry.setAttribute(
"instanceEnd",
new THREE.InstancedBufferAttribute(instanceEnds, 3),
);
satelliteTrails.geometry.setAttribute(
"instanceColorStart",
new THREE.InstancedBufferAttribute(instanceColorStarts, 3),
);
satelliteTrails.geometry.setAttribute(
"instanceColorEnd",
new THREE.InstancedBufferAttribute(instanceColorEnds, 3),
);
satelliteTrails.geometry.instanceCount = segCount;
satellitePositions = Array.from({ length: nextCapacity }, (_, index) => {
const previousState = previousSatellitePositions[index];
if (!previousState) {
return createSatellitePositionState(index);
}
return {
current: previousState.current.clone(),
trail: previousState.trail.slice(),
trailIndex: previousState.trailIndex,
trailCount: previousState.trailCount,
twinkle: previousState.twinkle || createSatelliteTwinkleState(index),
};
});
satelliteCapacity = nextCapacity;
}
function shouldHideSatellitePoint(index) {
return index === hoveredSatelliteIndex || index === lockedSatelliteIndex;
}
function updateSatellitePointVisibilityAttributes(count = satelliteData.length) {
const pointAlphaAttr = satellitePoints?.geometry?.attributes?.alpha;
const backdropAlphaAttr = satelliteBackdropPoints?.geometry?.attributes?.alpha;
if (!pointAlphaAttr?.array || !backdropAlphaAttr?.array) return;
const visibleCount = Math.min(count, pointAlphaAttr.array.length, backdropAlphaAttr.array.length);
for (let i = 0; i < visibleCount; i++) {
const alpha = shouldHideSatellitePoint(i) ? 0 : 1;
pointAlphaAttr.array[i] = alpha;
backdropAlphaAttr.array[i] = alpha;
}
pointAlphaAttr.needsUpdate = true;
backdropAlphaAttr.needsUpdate = true;
}
function computeSatellitePosition(satellite, time) {
try {
const props = satellite.properties;
if (!props || !props.norad_cat_id) {
return null;
}
const satrec = getOrBuildSatrec(props, time);
if (!satrec || satrec.error) {
return null;
}
const positionAndVelocity = propagate(satrec, time);
if (!positionAndVelocity || !positionAndVelocity.position) {
return null;
}
return computeDisplayPositionFromEciPosition(
positionAndVelocity.position,
gstime(time),
);
} catch (error) {
return null;
}
}
function computeDisplayPositionFromEciPosition(positionEci, siderealTime) {
const earthFixedPosition = convertEciPositionToSceneVector(
positionEci,
siderealTime,
);
const x = earthFixedPosition.x;
const y = earthFixedPosition.y;
const z = earthFixedPosition.z;
if (!Number.isFinite(x) || !Number.isFinite(y) || !Number.isFinite(z)) {
return null;
}
const r = Math.sqrt(
positionEci.x * positionEci.x +
positionEci.y * positionEci.y +
positionEci.z * positionEci.z,
);
if (!Number.isFinite(r) || r <= 0) {
return null;
}
const displayRadius = satelliteRealAltitudeEnabled
? CONFIG.earthRadius + getCompressedRealAltitudeOffset(r)
: CONFIG.earthRadius + SATELLITE_CONFIG.fallbackAltitudeOffset;
const sceneRadius = Math.sqrt(x * x + y * y + z * z);
if (!Number.isFinite(sceneRadius) || sceneRadius <= 0) {
return null;
}
const scale = displayRadius / sceneRadius;
return new THREE.Vector3(x * scale, y * scale, z * scale);
}
function computeSatelliteInertialOrbitPosition(satellite, time, siderealTime) {
try {
const props = satellite.properties;
if (!props || !props.norad_cat_id) {
return null;
}
const satrec = getOrBuildSatrec(props, time);
if (!satrec || satrec.error) {
return null;
}
const positionAndVelocity = propagate(satrec, time);
if (!positionAndVelocity || !positionAndVelocity.position) {
return null;
}
return computeDisplayPositionFromEciPosition(
positionAndVelocity.position,
siderealTime,
);
} catch (error) {
return null;
}
}
function convertEciPositionToSceneVector(positionEci, siderealTime) {
const positionEcf = eciToEcf(positionEci, siderealTime);
return new THREE.Vector3(
positionEcf.x,
positionEcf.z,
-positionEcf.y,
);
}
function getCompressedRealAltitudeOffset(radiusKm) {
const altitudeKm = Math.max(0, radiusKm - EARTH_RADIUS_KM);
const clampedAltitudeKm = Math.min(
altitudeKm,
SATELLITE_CONFIG.maxDisplayAltitudeKm,
);
const compressionKm = Math.max(1, SATELLITE_CONFIG.altitudeCompressionKm);
const normalizedAltitude = Math.log1p(clampedAltitudeKm / compressionKm) /
Math.log1p(SATELLITE_CONFIG.maxDisplayAltitudeKm / compressionKm);
return THREE.MathUtils.lerp(
SATELLITE_CONFIG.minRealAltitudeOffset,
SATELLITE_CONFIG.maxRealAltitudeOffset,
THREE.MathUtils.clamp(normalizedAltitude, 0, 1),
);
}
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 getSatelliteSatrecCacheKey(props) {
if (!props?.norad_cat_id) {
return null;
}
if (props.tle_line1 && props.tle_line2) {
return `tle:${props.norad_cat_id}:${props.tle_line1}:${props.tle_line2}`;
}
if (props.epoch) {
return [
"elements",
props.norad_cat_id,
props.epoch,
props.inclination,
props.raan,
props.eccentricity,
props.arg_of_perigee,
props.mean_anomaly,
props.mean_motion,
].join(":");
}
return null;
}
function getOrBuildSatrec(props, fallbackTime) {
const cacheKey = getSatelliteSatrecCacheKey(props);
if (cacheKey && satelliteSatrecCache.has(cacheKey)) {
return satelliteSatrecCache.get(cacheKey);
}
const satrec = buildSatrecFromProperties(props, fallbackTime);
if (cacheKey && satrec && !satrec.error) {
satelliteSatrecCache.set(cacheKey, satrec);
}
return satrec;
}
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, time = new Date()) {
const radius = CONFIG.earthRadius + SATELLITE_CONFIG.fallbackAltitudeOffset;
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 rawMeanMotion = Number(satellite.properties?.mean_motion);
const meanMotion =
Number.isFinite(rawMeanMotion) && rawMeanMotion > 0
? rawMeanMotion
: FALLBACK_MIN_MEAN_MOTION + randomOffset * FALLBACK_MEAN_MOTION_SPREAD;
const normalizedIndex = index / total;
const elapsedDays = Number.isFinite(time?.getTime?.())
? time.getTime() / FALLBACK_ORBIT_DAY_MS
: Date.now() / FALLBACK_ORBIT_DAY_MS;
const fallbackPhase = elapsedDays * meanMotion * Math.PI * 2;
const theta =
normalizedIndex * Math.PI * 2 * 10 +
(raan * Math.PI) / 180 +
fallbackPhase;
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(options = {}) {
const limit = getRequestedSatelliteLimit(options.limit);
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 || [];
satelliteSatrecCache = new Map();
resetSatelliteTrailState();
ensureSatelliteCapacity(satelliteData.length);
positionUpdateAccumulator = POSITION_UPDATE_INTERVAL_MS;
return {
count: satelliteData.length,
requestedLimit: limit,
};
}
export function updateSatellitePositions(deltaTime = 0, force = false, options = {}) {
if (!satellitePoints || !satelliteBackdropPoints || satelliteData.length === 0) return;
const shouldUpdateTrails =
showSatellites ||
showTrails ||
lockedSatelliteIndex !== null;
const shouldResetTrails =
options.resetTrails ||
(!force && deltaTime >= BACKGROUND_TRAIL_RESET_DELTA_MS);
if (shouldResetTrails) {
clearSatelliteTrails();
positionUpdateAccumulator = POSITION_UPDATE_INTERVAL_MS;
} else {
positionUpdateAccumulator += deltaTime;
}
if (!force && positionUpdateAccumulator < POSITION_UPDATE_INTERVAL_MS) {
return;
}
const elapsedMs = shouldResetTrails
? 0
: Math.max(
positionUpdateAccumulator,
POSITION_UPDATE_INTERVAL_MS,
);
positionUpdateAccumulator = 0;
const positions = satellitePoints.geometry.attributes.position.array;
const backdropPositions =
satelliteBackdropPoints.geometry.attributes.position.array;
const colors = satellitePoints.geometry.attributes.color.array;
const pointAlphas = satellitePoints.geometry.attributes.alpha.array;
const backdropAlphas = satelliteBackdropPoints.geometry.attributes.alpha.array;
const instanceStarts = satelliteTrails.geometry.attributes.instanceStart.array;
const instanceEnds = satelliteTrails.geometry.attributes.instanceEnd.array;
const instanceColorStarts = satelliteTrails.geometry.attributes.instanceColorStart.array;
const instanceColorEnds = satelliteTrails.geometry.attributes.instanceColorEnd.array;
const baseTime = new Date(Date.now() + elapsedMs);
const count = Math.min(satelliteData.length, satelliteCapacity);
let trailSegmentCount = 0;
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, adjustedTime);
}
satellitePositions[i].current.copy(pos);
if (shouldUpdateTrails) {
const satPos = satellitePositions[i];
if (satPos.trailCount === 0 && TRAIL_LENGTH > 1) {
for (let k = 0; k < TRAIL_LENGTH; k++) {
const offsetMs = (TRAIL_LENGTH - 1 - k) * POSITION_UPDATE_INTERVAL_MS;
const pastTime = new Date(adjustedTime.getTime() - offsetMs);
let pastPos = computeSatellitePosition(satellite, pastTime);
if (!pastPos) {
pastPos = generateFallbackPosition(satellite, i, count, pastTime);
}
satPos.trail[satPos.trailIndex] = pastPos;
satPos.trailIndex = (satPos.trailIndex + 1) % TRAIL_LENGTH;
}
satPos.trailCount = TRAIL_LENGTH;
} else {
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;
backdropPositions[i * 3] = pos.x;
backdropPositions[i * 3 + 1] = pos.y;
backdropPositions[i * 3 + 2] = pos.z;
const rule = getSatelliteLegendRule(props);
const { r, g, b } = getSatelliteRuleColor(rule);
const isNonFocusDimmed =
highlightedSatelliteIndices !== null && !highlightedSatelliteIndices.has(i);
const pointBrightness = isNonFocusDimmed ? DIMMED_SATELLITE_BRIGHTNESS : 1;
const trailBrightness = isNonFocusDimmed
? DIMMED_SATELLITE_TRAIL_BRIGHTNESS
: 1;
colors[i * 3] = r * pointBrightness;
colors[i * 3 + 1] = g * pointBrightness;
colors[i * 3 + 2] = b * pointBrightness;
const pointAlpha = shouldHideSatellitePoint(i) ? 0 : 1;
pointAlphas[i] = pointAlpha;
backdropAlphas[i] = pointAlpha;
const satPosition = satellitePositions[i];
const tc = satPosition.trailCount;
let hasVisibleTrail = false;
for (let j = 0; j < TRAIL_LENGTH - 1; j++) {
if (j + 1 < tc) {
const idxA =
(satPosition.trailIndex - tc + j + TRAIL_LENGTH) % TRAIL_LENGTH;
const idxB =
(satPosition.trailIndex - tc + j + 1 + TRAIL_LENGTH) % TRAIL_LENGTH;
const ptA = satPosition.trail[idxA];
const ptB = satPosition.trail[idxB];
if (ptA && ptB && ptA.distanceToSquared(ptB) > 1e-8) {
const base = trailSegmentCount * 3;
instanceStarts[base] = ptA.x;
instanceStarts[base + 1] = ptA.y;
instanceStarts[base + 2] = ptA.z;
instanceEnds[base] = ptB.x;
instanceEnds[base + 1] = ptB.y;
instanceEnds[base + 2] = ptB.z;
const alphaA = (j + 1) / tc;
const alphaB = (j + 2) / tc;
instanceColorStarts[base] = r * alphaA * trailBrightness;
instanceColorStarts[base + 1] = g * alphaA * trailBrightness;
instanceColorStarts[base + 2] = b * alphaA * trailBrightness;
instanceColorEnds[base] = r * alphaB * trailBrightness;
instanceColorEnds[base + 1] = g * alphaB * trailBrightness;
instanceColorEnds[base + 2] = b * alphaB * trailBrightness;
hasVisibleTrail = true;
trailSegmentCount++;
}
}
}
if (!hasVisibleTrail) {
const base = trailSegmentCount * 3;
const dist = Math.sqrt(pos.x * pos.x + pos.y * pos.y + pos.z * pos.z) || 1;
const nx = pos.x / dist;
const ny = pos.y / dist;
const nz = pos.z / dist;
const tip = FALLBACK_TRAIL_TIP_LENGTH;
instanceStarts[base] = pos.x + nx * tip;
instanceStarts[base + 1] = pos.y + ny * tip;
instanceStarts[base + 2] = pos.z + nz * tip;
instanceEnds[base] = pos.x;
instanceEnds[base + 1] = pos.y;
instanceEnds[base + 2] = pos.z;
const fallbackAlphaStart = FALLBACK_TRAIL_ALPHA_START * trailBrightness;
const fallbackAlphaEnd = FALLBACK_TRAIL_ALPHA_END * trailBrightness;
instanceColorStarts[base] = r * fallbackAlphaStart;
instanceColorStarts[base + 1] = g * fallbackAlphaStart;
instanceColorStarts[base + 2] = b * fallbackAlphaStart;
instanceColorEnds[base] = r * fallbackAlphaEnd;
instanceColorEnds[base + 1] = g * fallbackAlphaEnd;
instanceColorEnds[base + 2] = b * fallbackAlphaEnd;
trailSegmentCount++;
}
}
for (let i = count; i < satelliteCapacity; i++) {
positions[i * 3] = 0;
positions[i * 3 + 1] = 0;
positions[i * 3 + 2] = 0;
backdropPositions[i * 3] = 0;
backdropPositions[i * 3 + 1] = 0;
backdropPositions[i * 3 + 2] = 0;
pointAlphas[i] = 0;
backdropAlphas[i] = 0;
}
const trailArrayLength = instanceStarts.length / 3;
for (let i = trailSegmentCount; i < trailArrayLength; i++) {
const base = i * 3;
instanceStarts[base] = 0;
instanceStarts[base + 1] = 0;
instanceStarts[base + 2] = 0;
instanceEnds[base] = 0;
instanceEnds[base + 1] = 0;
instanceEnds[base + 2] = 0;
instanceColorStarts[base] = 0;
instanceColorStarts[base + 1] = 0;
instanceColorStarts[base + 2] = 0;
instanceColorEnds[base] = 0;
instanceColorEnds[base + 1] = 0;
instanceColorEnds[base + 2] = 0;
}
satellitePoints.geometry.attributes.position.needsUpdate = true;
satellitePoints.geometry.attributes.color.needsUpdate = true;
satellitePoints.geometry.attributes.alpha.needsUpdate = true;
satellitePoints.geometry.setDrawRange(0, count);
satelliteBackdropPoints.geometry.attributes.position.needsUpdate = true;
satelliteBackdropPoints.geometry.attributes.alpha.needsUpdate = true;
satelliteBackdropPoints.geometry.setDrawRange(0, count);
for (const name of TRAIL_INSTANCE_ATTRIBUTE_NAMES) {
satelliteTrails.geometry.attributes[name].needsUpdate = true;
}
satelliteTrails.geometry.instanceCount = trailSegmentCount;
// 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 (satelliteBackdropPoints) {
satelliteBackdropPoints.visible = 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 getShowTrails() {
return showTrails;
}
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 setSatelliteSunDirection(direction) {
if (!direction) return;
satelliteSunDirection.copy(direction).normalize();
if (lockedGroundFootprintFillMesh?.material?.uniforms?.uSunDirectionWorld) {
lockedGroundFootprintFillMesh.material.uniforms.uSunDirectionWorld.value.copy(
satelliteSunDirection,
);
}
}
export function setLockedSatelliteIndex(index) {
lockedSatelliteIndex = index;
updateSatellitePointVisibilityAttributes();
}
export function setHoveredSatelliteIndex(index) {
hoveredSatelliteIndex = index;
updateSatellitePointVisibilityAttributes();
}
function normalizeSatelliteDisplayStyle(nextStyle) {
return Object.values(SATELLITE_DISPLAY_STYLES).includes(nextStyle)
? nextStyle
: DEFAULT_SATELLITE_DISPLAY_STYLE;
}
function normalizeSatelliteConstellationGroup(rawGroup) {
const normalized = String(rawGroup || "")
.trim()
.toLowerCase();
return normalized || null;
}
function inferSatelliteConstellationGroup(props = {}) {
const explicitGroup = normalizeSatelliteConstellationGroup(
props.constellation_group,
);
if (explicitGroup) {
return explicitGroup;
}
const normalizedName = String(props.name || "")
.trim()
.toUpperCase();
if (normalizedName.startsWith("STARLINK")) {
return "starlink";
}
if (normalizedName.startsWith("IRIDIUM")) {
return "iridium-next";
}
return null;
}
function getSatelliteFootprintPolicy(props = {}) {
const explicitPolicy = String(props.footprint_policy || "")
.trim()
.toLowerCase();
if (Object.values(SATELLITE_FOOTPRINT_POLICIES).includes(explicitPolicy)) {
return explicitPolicy;
}
const constellationGroup = inferSatelliteConstellationGroup(props);
if (constellationGroup === "starlink") {
return SATELLITE_FOOTPRINT_POLICIES.STARLINK_GROUND_FOOTPRINT;
}
if (constellationGroup === "iridium-next") {
return SATELLITE_FOOTPRINT_POLICIES.IRIDIUM_COVERAGE_RING;
}
return SATELLITE_FOOTPRINT_POLICIES.NONE;
}
function getSatelliteConstellationLabel(props = {}) {
const constellationGroup = inferSatelliteConstellationGroup(props);
if (!constellationGroup) return "未分类";
return (
SATELLITE_CONSTELLATION_LABELS[constellationGroup] ||
constellationGroup
);
}
function getSatellitePresentationMode(props = {}) {
if (satelliteDisplayStyle !== SATELLITE_DISPLAY_STYLES.GROUND_FOOTPRINT) {
return SATELLITE_PRESENTATION_MODES.SELF_GLOW;
}
const footprintPolicy = getSatelliteFootprintPolicy(props);
if (
footprintPolicy ===
SATELLITE_FOOTPRINT_POLICIES.STARLINK_GROUND_FOOTPRINT
) {
return SATELLITE_PRESENTATION_MODES.STARLINK_GROUND_FOOTPRINT;
}
if (footprintPolicy === SATELLITE_FOOTPRINT_POLICIES.IRIDIUM_COVERAGE_RING) {
return SATELLITE_PRESENTATION_MODES.IRIDIUM_SPOT_BEAMS;
}
return SATELLITE_PRESENTATION_MODES.SELF_GLOW;
}
function getSatelliteFootprintCapabilityLabel(props = {}) {
const footprintPolicy = getSatelliteFootprintPolicy(props);
switch (footprintPolicy) {
case SATELLITE_FOOTPRINT_POLICIES.STARLINK_GROUND_FOOTPRINT:
return "支持 Starlink 地表覆盖";
case SATELLITE_FOOTPRINT_POLICIES.IRIDIUM_COVERAGE_RING:
return "支持 Iridium 外圈覆盖";
default:
return "默认不显示 footprint";
}
}
function getSatellitePresentationModeLabel(mode) {
switch (mode) {
case SATELLITE_PRESENTATION_MODES.STARLINK_GROUND_FOOTPRINT:
return "真实地表覆盖Starlink";
case SATELLITE_PRESENTATION_MODES.IRIDIUM_SPOT_BEAMS:
return "真实地表覆盖Iridium 外圈)";
default:
return "自身发光";
}
}
export function getSatellitePresentationInfo(props = {}) {
const footprintPolicy = getSatelliteFootprintPolicy(props);
const presentationMode = getSatellitePresentationMode(props);
return {
constellationGroup: inferSatelliteConstellationGroup(props),
constellationLabel: getSatelliteConstellationLabel(props),
footprintPolicy,
footprintCapabilityLabel: getSatelliteFootprintCapabilityLabel(props),
presentationMode,
presentationModeLabel: getSatellitePresentationModeLabel(
presentationMode,
),
};
}
function getLockedSatelliteProperties() {
if (lockedSatelliteIndex === null) return null;
return satelliteData[lockedSatelliteIndex]?.properties || null;
}
export function getSatelliteDisplayStyle() {
return satelliteDisplayStyle;
}
export function setSatelliteDisplayStyle(nextStyle) {
const normalizedStyle = normalizeSatelliteDisplayStyle(nextStyle);
if (normalizedStyle === satelliteDisplayStyle) return satelliteDisplayStyle;
satelliteDisplayStyle = normalizedStyle;
if (
lockedSatelliteIndex !== null &&
satellitePositions?.[lockedSatelliteIndex]?.current
) {
showHoverRing(satellitePositions[lockedSatelliteIndex].current, true);
} else {
clearLockedSatelliteStyleVisuals();
}
return satelliteDisplayStyle;
}
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) >
SATELLITE_CONFIG.frontFacingDotThreshold
);
}
function createLockedHaloMaterial(color = "#ffbf47") {
return new THREE.ShaderMaterial({
transparent: true,
depthTest: true,
depthWrite: false,
side: THREE.DoubleSide,
uniforms: {
uColor: { value: new THREE.Color(color) },
uOpacity: { value: LOCKED_HALO_BASE_OPACITY },
},
vertexShader: `
varying vec2 vUv;
void main() {
vUv = uv;
gl_Position = projectionMatrix * modelViewMatrix * vec4(position, 1.0);
}
`,
fragmentShader: `
uniform vec3 uColor;
uniform float uOpacity;
varying vec2 vUv;
void main() {
vec2 centeredUv = vUv - vec2(0.5);
float distanceToCenter = length(centeredUv) * 2.0;
float outerFade = 1.0 - smoothstep(0.34, 1.0, distanceToCenter);
float innerFade = smoothstep(0.18, 0.48, distanceToCenter);
float alpha = outerFade * innerFade * uOpacity;
if (alpha <= 0.001) discard;
gl_FragColor = vec4(uColor, alpha);
}
`,
});
}
function createGroundFootprintMaterial() {
return new THREE.ShaderMaterial({
transparent: true,
side: THREE.DoubleSide,
depthTest: true,
depthWrite: false,
uniforms: {
uColor: { value: new THREE.Color(0xffffff) },
uOpacity: { value: 0.46 },
uMajorKm: { value: 1000 },
uMinorKm: { value: 700 },
uGapCenterNorthKm: { value: 0 },
uGapLengthKm: { value: 1000 },
uGapWidthCenterKm: { value: GROUND_FOOTPRINT_GAP_WIDTH_CENTER_KM },
uGapWidthEdgeKm: { value: GROUND_FOOTPRINT_GAP_WIDTH_EDGE_KM },
uEastAlongDot: { value: 1 },
uEastCrossDot: { value: 0 },
uNorthAlongDot: { value: 0 },
uNorthCrossDot: { value: 1 },
uSoftOuterStart: { value: 0.0 },
uSoftOuterEnd: { value: 1.0 },
uGapSoftnessKm: { value: 6 },
uGlowMode: { value: 0 },
uSunDirectionWorld: { value: satelliteSunDirection.clone() },
uDayVisibilityBoost: { value: 1.48 },
},
vertexShader: `
varying vec3 vWorldPosition;
varying vec2 vUv;
void main() {
vUv = uv;
vec4 worldPosition = modelMatrix * vec4(position, 1.0);
vWorldPosition = worldPosition.xyz;
gl_Position = projectionMatrix * modelViewMatrix * vec4(position, 1.0);
}
`,
fragmentShader: `
uniform vec3 uColor;
uniform float uOpacity;
uniform float uMajorKm;
uniform float uMinorKm;
uniform float uGapCenterNorthKm;
uniform float uGapLengthKm;
uniform float uGapWidthCenterKm;
uniform float uGapWidthEdgeKm;
uniform float uEastAlongDot;
uniform float uEastCrossDot;
uniform float uNorthAlongDot;
uniform float uNorthCrossDot;
uniform float uSoftOuterStart;
uniform float uSoftOuterEnd;
uniform float uGapSoftnessKm;
uniform float uGlowMode;
uniform vec3 uSunDirectionWorld;
uniform float uDayVisibilityBoost;
varying vec3 vWorldPosition;
varying vec2 vUv;
float bowtieHalfWidth(float xEast) {
float t = clamp(abs(xEast) / max(uGapLengthKm, 1.0), 0.0, 1.0);
return mix(uGapWidthCenterKm, uGapWidthEdgeKm, pow(t, 1.7));
}
void main() {
vec2 p = vUv * 2.0 - 1.0;
float ellipseMetric = dot(p, p);
float centerGlow = exp(-ellipseMetric * 0.5);
float edgeFade = 1.0 - smoothstep(0.28, 1.0, ellipseMetric);
float outerAlpha = centerGlow * pow(max(edgeFade, 0.0), 1.45);
if (outerAlpha <= 0.001) discard;
float alongKm = p.x * uMajorKm;
float crossKm = p.y * uMinorKm;
float xEast = alongKm * uEastAlongDot + crossKm * uEastCrossDot;
float yNorth = alongKm * uNorthAlongDot + crossKm * uNorthCrossDot;
float gapMask = 1.0;
if (abs(xEast) <= uGapLengthKm) {
float gapHalfWidth = bowtieHalfWidth(xEast);
float distToGap = abs(yNorth - uGapCenterNorthKm) - gapHalfWidth;
gapMask = smoothstep(-uGapSoftnessKm, uGapSoftnessKm, distToGap);
}
float alpha = outerAlpha * gapMask * uOpacity;
if (uGlowMode > 0.5) {
alpha *= 0.92;
} else {
alpha *= 1.34;
}
vec3 worldNormal = normalize(vWorldPosition);
vec3 toCam = normalize(cameraPosition - vWorldPosition);
float facing = dot(worldNormal, toCam);
float limbFade = smoothstep(-0.08, 0.12, facing);
if (limbFade <= 0.0) discard;
alpha *= limbFade;
vec3 sunDir = normalize(uSunDirectionWorld);
float sunFacing = dot(worldNormal, sunDir);
float daylight = clamp(sunFacing * 0.5 + 0.5, 0.0, 1.0);
alpha *= mix(1.0, uDayVisibilityBoost, daylight);
vec3 nightColor = vec3(0.24, 0.56, 1.0);
vec3 dayColor = vec3(1.0, 0.72, 0.08);
vec3 finalColor = mix(nightColor, dayColor, daylight);
if (alpha <= 0.001) discard;
gl_FragColor = vec4(finalColor, alpha);
}
`,
});
}
function smoothstep(edge0, edge1, x) {
const t = THREE.MathUtils.clamp((x - edge0) / (edge1 - edge0), 0, 1);
return t * t * (3 - 2 * t);
}
function centralAngleForMinElevation(heightKm, elevationDeg) {
const elevationRad = THREE.MathUtils.degToRad(elevationDeg);
const orbitalRadiusKm = EARTH_RADIUS_KM + heightKm;
let low = 0;
let high = Math.acos(EARTH_RADIUS_KM / orbitalRadiusKm) - 1e-5;
function elevationAt(gamma) {
const ground = new THREE.Vector3(
EARTH_RADIUS_KM * Math.cos(gamma),
EARTH_RADIUS_KM * Math.sin(gamma),
0,
);
const satellite = new THREE.Vector3(orbitalRadiusKm, 0, 0);
const surfaceNormal = ground.clone().normalize();
const toSatellite = satellite.clone().sub(ground).normalize();
return Math.asin(
THREE.MathUtils.clamp(surfaceNormal.dot(toSatellite), -1, 1),
);
}
for (let iteration = 0; iteration < 48; iteration += 1) {
const mid = (low + high) * 0.5;
if (elevationAt(mid) > elevationRad) {
low = mid;
} else {
high = mid;
}
}
return low;
}
function clearLockedSatelliteStyleVisuals() {
if (lockedDotSprite) {
disposeObject3D(lockedDotSprite, sceneRef);
lockedDotSprite = null;
}
if (lockedHaloMesh) {
disposeObject3D(lockedHaloMesh, sceneRef);
lockedHaloMesh = null;
}
if (lockedGroundFootprintMesh) {
disposeObjectTree(lockedGroundFootprintMesh);
lockedGroundFootprintMesh = null;
lockedGroundFootprintFillMesh = null;
hasGroundFootprintGeometry = false;
}
if (lockedIridiumFootprintMesh) {
disposeIridiumFootprintAdapter(lockedIridiumFootprintMesh, earthObjRef);
lockedIridiumFootprintMesh = null;
}
}
function updateLockedDotWorldTransform(position) {
if (!lockedDotSprite || !position || !earthObjRef) return;
earthObjRef.updateMatrixWorld(true);
const worldPosition = position.clone().applyMatrix4(earthObjRef.matrixWorld);
lockedDotSprite.position.copy(worldPosition);
if (cameraRef) {
lockedDotSprite.quaternion.copy(cameraRef.quaternion);
}
const viewportHeight = window.innerHeight || 1080;
const distanceToCamera = cameraRef
? Math.max(cameraRef.position.distanceTo(worldPosition), 1)
: CONFIG.defaultCameraZ;
const verticalFovRad = cameraRef?.isPerspectiveCamera
? THREE.MathUtils.degToRad(cameraRef.fov)
: THREE.MathUtils.degToRad(45);
const worldUnitsPerPixel =
(2 * Math.tan(verticalFovRad / 2) * distanceToCamera) / viewportHeight;
const coreRadiusWorld = worldUnitsPerPixel * LOCKED_HALO_CORE_PIXEL_RADIUS;
lockedDotSprite.scale.set(coreRadiusWorld, coreRadiusWorld, 1);
}
function updateLockedHaloWorldTransform(position) {
if (!position || !earthObjRef || !lockedHaloMesh) return;
earthObjRef.updateMatrixWorld(true);
const worldPosition = position.clone().applyMatrix4(earthObjRef.matrixWorld);
const viewDirection = cameraRef
? scratchToCamera.subVectors(cameraRef.position, worldPosition).normalize()
: null;
lockedHaloMesh.position.copy(worldPosition);
if (viewDirection) {
lockedHaloMesh.position.addScaledVector(viewDirection, -LOCKED_HALO_OFFSET);
}
if (cameraRef) {
lockedHaloMesh.quaternion.copy(cameraRef.quaternion);
}
const viewportHeight = window.innerHeight || 1080;
const distanceToCamera = cameraRef
? Math.max(cameraRef.position.distanceTo(worldPosition), 1)
: CONFIG.defaultCameraZ;
const verticalFovRad = cameraRef?.isPerspectiveCamera
? THREE.MathUtils.degToRad(cameraRef.fov)
: THREE.MathUtils.degToRad(45);
const worldUnitsPerPixel =
(2 * Math.tan(verticalFovRad / 2) * distanceToCamera) / viewportHeight;
const haloRadiusWorld = worldUnitsPerPixel * LOCKED_HALO_PIXEL_RADIUS;
lockedHaloMesh.scale.set(haloRadiusWorld, haloRadiusWorld, 1);
}
function estimateLockedSatelliteAltitudeKm() {
if (lockedSatelliteIndex === null) return GROUND_FOOTPRINT_DEFAULT_ALTITUDE_KM;
const satellite = satelliteData[lockedSatelliteIndex];
const props = satellite?.properties;
if (!props?.norad_cat_id) return GROUND_FOOTPRINT_DEFAULT_ALTITUDE_KM;
const satrec = getOrBuildSatrec(props, new Date());
if (!satrec || satrec.error) return GROUND_FOOTPRINT_DEFAULT_ALTITUDE_KM;
const propagation = propagate(satrec, new Date());
const rawPosition = propagation?.position;
if (!rawPosition) return GROUND_FOOTPRINT_DEFAULT_ALTITUDE_KM;
const radiusKm = Math.sqrt(
rawPosition.x * rawPosition.x +
rawPosition.y * rawPosition.y +
rawPosition.z * rawPosition.z,
);
if (!Number.isFinite(radiusKm)) return GROUND_FOOTPRINT_DEFAULT_ALTITUDE_KM;
return Math.max(0, radiusKm - EARTH_RADIUS_KM);
}
function estimateGroundCoverageAngleRad(altitudeKm) {
return Math.max(
0.03,
centralAngleForMinElevation(
Math.max(altitudeKm, 10),
GROUND_FOOTPRINT_MIN_ELEVATION_DEG,
),
);
}
function getLockedSatelliteTrackDirection(groundNormal) {
if (lockedSatelliteIndex === null) return null;
const satellite = satelliteData[lockedSatelliteIndex];
const props = satellite?.properties;
if (!props?.norad_cat_id) return null;
const now = new Date();
const currentPosition = computeSatellitePosition(satellite, now);
const nextPosition = computeSatellitePosition(
satellite,
new Date(now.getTime() + FOOTPRINT_DIRECTION_SAMPLE_MS),
);
if (!currentPosition || !nextPosition) return null;
scratchFootprintVelocity.subVectors(nextPosition, currentPosition);
if (!Number.isFinite(scratchFootprintVelocity.lengthSq())) return null;
scratchFootprintTangent
.copy(scratchFootprintVelocity)
.projectOnPlane(groundNormal);
if (scratchFootprintTangent.lengthSq() <= 1e-6) {
return null;
}
return scratchFootprintTangent.normalize().clone();
}
function buildSurfaceFrame(position) {
const centerNormal = position.clone().normalize();
const alongTrack =
getLockedSatelliteTrackDirection(centerNormal) ||
scratchFootprintTrack.set(0, 1, 0).projectOnPlane(centerNormal).normalize();
if (alongTrack.lengthSq() <= 1e-6) {
alongTrack.copy(scratchFootprintReference.set(1, 0, 0));
}
const crossTrack = scratchFootprintLateral
.crossVectors(centerNormal, alongTrack)
.normalize()
.clone();
return {
centerNormal,
alongTrack: alongTrack.clone(),
crossTrack,
};
}
function projectFootprintOffsetToSurface(
centerNormal,
alongTrack,
crossTrack,
alongKm,
crossKm,
surfaceRadius,
) {
const worldUnitsPerKm = CONFIG.earthRadius / EARTH_RADIUS_KM;
return centerNormal
.clone()
.multiplyScalar(CONFIG.earthRadius)
.addScaledVector(alongTrack, alongKm * worldUnitsPerKm)
.addScaledVector(crossTrack, crossKm * worldUnitsPerKm)
.normalize()
.multiplyScalar(surfaceRadius);
}
function buildGroundFootprintGeometry(position) {
const altitudeKm = estimateLockedSatelliteAltitudeKm();
const coverageRadiusKm =
EARTH_RADIUS_KM *
estimateGroundCoverageAngleRad(altitudeKm) *
GROUND_FOOTPRINT_SERVICE_RADIUS_FACTOR;
const surfaceRadius =
CONFIG.earthRadius * GROUND_FOOTPRINT_SURFACE_SCALE +
GROUND_FOOTPRINT_RADIUS_OFFSET;
const { centerNormal, alongTrack, crossTrack } = buildSurfaceFrame(position);
const absLatitudeDeg = Math.abs(
THREE.MathUtils.radToDeg(Math.asin(centerNormal.y)),
);
const latitudeBlend = THREE.MathUtils.clamp(
absLatitudeDeg / GROUND_FOOTPRINT_LATITUDE_BLEND_DEG,
0,
1,
);
const axisRatio = THREE.MathUtils.lerp(
GROUND_FOOTPRINT_LOW_LAT_AXIS_RATIO,
GROUND_FOOTPRINT_HIGH_LAT_AXIS_RATIO,
latitudeBlend,
);
const majorKm = coverageRadiusKm * axisRatio;
const minorKm = coverageRadiusKm / axisRatio;
const worldNorth = new THREE.Vector3(0, 1, 0);
let east = scratchFootprintReference
.crossVectors(worldNorth, centerNormal)
.normalize()
.clone();
if (east.lengthSq() < 1e-6) {
east = alongTrack.clone();
}
const north = new THREE.Vector3().crossVectors(centerNormal, east).normalize();
const latitudeSign = centerNormal.y >= 0 ? 1 : -1;
const gapCenterNorthKm =
latitudeSign *
THREE.MathUtils.lerp(
GROUND_FOOTPRINT_GAP_CENTER_MIN_RATIO * minorKm,
GROUND_FOOTPRINT_GAP_CENTER_MAX_RATIO * minorKm,
smoothstep(0.06, 0.95, latitudeBlend),
);
const exclusionLengthKm = GROUND_FOOTPRINT_GAP_LENGTH_RATIO * majorKm;
function isInsideEllipse(alongKm, crossKm) {
return (
(alongKm * alongKm) / (majorKm * majorKm) +
(crossKm * crossKm) / (minorKm * minorKm) <=
1
);
}
function bowtieHalfWidth(xEast) {
const t = THREE.MathUtils.clamp(
Math.abs(xEast) / Math.max(exclusionLengthKm, 1),
0,
1,
);
return THREE.MathUtils.lerp(
GROUND_FOOTPRINT_GAP_WIDTH_CENTER_KM,
GROUND_FOOTPRINT_GAP_WIDTH_EDGE_KM,
Math.pow(t, 1.7),
);
}
const vertices = [];
const uvs = [];
const indices = [];
const indexMap = [];
for (let iy = 0; iy <= GROUND_FOOTPRINT_GRID_Y; iy += 1) {
const row = [];
const crossKm = THREE.MathUtils.lerp(
-minorKm,
minorKm,
iy / GROUND_FOOTPRINT_GRID_Y,
);
for (let ix = 0; ix <= GROUND_FOOTPRINT_GRID_X; ix += 1) {
const alongKm = THREE.MathUtils.lerp(
-majorKm,
majorKm,
ix / GROUND_FOOTPRINT_GRID_X,
);
if (!isInsideEllipse(alongKm, crossKm)) {
row.push(-1);
continue;
}
const point = projectFootprintOffsetToSurface(
centerNormal,
alongTrack,
crossTrack,
alongKm,
crossKm,
surfaceRadius,
);
row.push(vertices.length / 3);
vertices.push(point.x, point.y, point.z);
uvs.push(
THREE.MathUtils.mapLinear(alongKm, -majorKm, majorKm, 0, 1),
THREE.MathUtils.mapLinear(crossKm, -minorKm, minorKm, 0, 1),
);
}
indexMap.push(row);
}
for (let iy = 0; iy < GROUND_FOOTPRINT_GRID_Y; iy += 1) {
for (let ix = 0; ix < GROUND_FOOTPRINT_GRID_X; ix += 1) {
const a = indexMap[iy][ix];
const b = indexMap[iy][ix + 1];
const c = indexMap[iy + 1][ix];
const d = indexMap[iy + 1][ix + 1];
if (a < 0 || b < 0 || c < 0 || d < 0) continue;
indices.push(a, c, b);
indices.push(b, c, d);
}
}
const fillGeometry = new THREE.BufferGeometry();
fillGeometry.setAttribute(
"position",
new THREE.Float32BufferAttribute(vertices, 3),
);
fillGeometry.setAttribute("uv", new THREE.Float32BufferAttribute(uvs, 2));
fillGeometry.setIndex(indices);
const basisToEastNorth = {
eastAlongDot: alongTrack.dot(east),
eastCrossDot: crossTrack.dot(east),
northAlongDot: alongTrack.dot(north),
northCrossDot: crossTrack.dot(north),
};
return {
fillGeometry,
majorKm,
minorKm,
gapCenterNorthKm,
exclusionLengthKm,
basisToEastNorth,
};
}
function updateGroundFootprintTransform(position) {
if (!lockedGroundFootprintMesh || !position || !earthObjRef) return;
if (
hasGroundFootprintGeometry &&
scratchLastGroundFootprintPosition.distanceToSquared(position) <=
GROUND_FOOTPRINT_REBUILD_DISTANCE_SQ
) {
return;
}
const geometrySet = buildGroundFootprintGeometry(position);
if (!geometrySet) return;
const fillMesh = lockedGroundFootprintFillMesh;
if (fillMesh?.geometry) fillMesh.geometry.dispose();
if (fillMesh) {
fillMesh.geometry = geometrySet.fillGeometry;
if (fillMesh.material?.uniforms) {
fillMesh.material.uniforms.uMajorKm.value = geometrySet.majorKm;
fillMesh.material.uniforms.uMinorKm.value = geometrySet.minorKm;
fillMesh.material.uniforms.uGapCenterNorthKm.value =
geometrySet.gapCenterNorthKm;
fillMesh.material.uniforms.uGapLengthKm.value =
geometrySet.exclusionLengthKm;
fillMesh.material.uniforms.uEastAlongDot.value =
geometrySet.basisToEastNorth.eastAlongDot;
fillMesh.material.uniforms.uEastCrossDot.value =
geometrySet.basisToEastNorth.eastCrossDot;
fillMesh.material.uniforms.uNorthAlongDot.value =
geometrySet.basisToEastNorth.northAlongDot;
fillMesh.material.uniforms.uNorthCrossDot.value =
geometrySet.basisToEastNorth.northCrossDot;
}
scratchLastGroundFootprintPosition.copy(position);
hasGroundFootprintGeometry = true;
}
}
function showSelfGlowStyle(position, color = "#ffd25a") {
const dotGeometry = new THREE.CircleGeometry(
LOCKED_HALO_CORE_RADIUS,
LOCKED_HALO_CORE_SEGMENTS,
);
const dotMaterial = new THREE.MeshBasicMaterial({
color: new THREE.Color(color),
transparent: true,
opacity: 0.96,
depthTest: true,
depthWrite: false,
side: THREE.DoubleSide,
});
lockedDotSprite = new THREE.Mesh(dotGeometry, dotMaterial);
lockedDotSprite.renderOrder = SATELLITE_CONFIG.overlayRenderOrder + 2;
updateLockedDotWorldTransform(position);
sceneRef?.add(lockedDotSprite);
lockedHaloMesh = new THREE.Mesh(
new THREE.CircleGeometry(LOCKED_HALO_RADIUS, 64),
createLockedHaloMaterial(color),
);
lockedHaloMesh.renderOrder = SATELLITE_CONFIG.overlayRenderOrder + 1;
sceneRef?.add(lockedHaloMesh);
updateLockedHaloWorldTransform(position);
}
function showGroundFootprintStyle(position) {
if (!earthObjRef) return;
lockedGroundFootprintMesh = new THREE.Group();
lockedGroundFootprintMesh.renderOrder = 0;
const fill = new THREE.Mesh(
new THREE.BufferGeometry(),
createGroundFootprintMaterial(),
);
fill.name = "footprint-fill";
fill.renderOrder = GROUND_FOOTPRINT_RENDER_ORDER;
lockedGroundFootprintMesh.add(fill);
lockedGroundFootprintFillMesh = fill;
hasGroundFootprintGeometry = false;
earthObjRef.add(lockedGroundFootprintMesh);
updateGroundFootprintTransform(position);
}
function showIridiumReservedStyle(position) {
if (!earthObjRef || !position) return;
lockedIridiumFootprintMesh = createIridiumFootprintAdapter({
earthObj: earthObjRef,
earthRadiusWorld: CONFIG.earthRadius,
renderOrder: GROUND_FOOTPRINT_RENDER_ORDER,
});
updateIridiumReservedStyle(position);
}
function updateIridiumReservedStyle(position) {
if (!lockedIridiumFootprintMesh || !position) return;
const { alongTrack, crossTrack } = buildSurfaceFrame(position);
updateIridiumFootprintAdapter(lockedIridiumFootprintMesh, {
position,
alongTrack,
crossTrack,
altitudeKm: estimateLockedSatelliteAltitudeKm(),
});
}
function createRingSprite(position, isLocked = false, color = "#ffcc00") {
if (!earthObjRef) return null;
const ringTexture = createRingTexture(
8,
12,
isLocked ? color : "#ffffff",
isLocked ? LOCKED_RING_HOVER_LINE_WIDTH : HOVER_RING_LINE_WIDTH,
);
const filledTexture = isLocked
? createFilledCircleTexture(color)
: null;
const spriteMaterial = new THREE.SpriteMaterial({
map: ringTexture,
transparent: true,
opacity: 0.8,
depthTest: true,
depthWrite: false,
alphaTest: 0.01,
sizeAttenuation: false,
});
const sprite = new THREE.Sprite(spriteMaterial);
sprite.userData.ringTexture = ringTexture;
sprite.userData.filledTexture = filledTexture;
sprite.position.copy(position);
sprite.scale.set(SATELLITE_CONFIG.ringSize, SATELLITE_CONFIG.ringSize, 1);
sprite.renderOrder = SATELLITE_CONFIG.overlayRenderOrder;
earthObjRef.add(sprite);
return sprite;
}
function isLockedSatelliteHovered() {
return (
lockedSatelliteIndex !== null &&
hoveredSatelliteIndex !== null &&
hoveredSatelliteIndex === lockedSatelliteIndex
);
}
function updateLockedMarkerVisual(isHovered) {
if (!lockedRingSprite?.material) return;
const nextTexture = isHovered
? lockedRingSprite.userData.ringTexture
: lockedRingSprite.userData.filledTexture || lockedRingSprite.userData.ringTexture;
if (lockedRingSprite.material.map !== nextTexture) {
lockedRingSprite.material.map = nextTexture;
lockedRingSprite.material.needsUpdate = true;
}
}
function createRelatedSatelliteSprite(position) {
return createRingSprite(position, false);
}
export function showHoverRing(position, isLocked = false) {
if (!earthObjRef || !position) return null;
if (isLocked) {
const lockedProps = getLockedSatelliteProperties() || {};
const legendColor = getSatelliteLegendRule(lockedProps).color;
hideLockedRing();
lockedRingSprite = createRingSprite(position, true, legendColor);
updateLockedMarkerVisual(isLockedSatelliteHovered());
const presentationMode = getSatellitePresentationMode(lockedProps);
if (
presentationMode ===
SATELLITE_PRESENTATION_MODES.STARLINK_GROUND_FOOTPRINT
) {
showGroundFootprintStyle(position);
} else if (
presentationMode === SATELLITE_PRESENTATION_MODES.IRIDIUM_SPOT_BEAMS
) {
showIridiumReservedStyle(position);
} else {
showSelfGlowStyle(position, legendColor);
}
return lockedRingSprite;
}
hideHoverRings();
hoverRingSprite = createRingSprite(position, false);
return hoverRingSprite;
}
export function hideHoverRings() {
if (hoverRingSprite) {
disposeObject3D(hoverRingSprite);
hoverRingSprite = null;
}
}
export function hideLockedRing() {
if (lockedRingSprite) {
const ringTexture = lockedRingSprite.userData?.ringTexture;
const filledTexture = lockedRingSprite.userData?.filledTexture;
disposeObject3D(lockedRingSprite);
if (ringTexture && ringTexture !== lockedRingSprite.material?.map) {
ringTexture.dispose();
}
if (filledTexture && filledTexture !== lockedRingSprite.material?.map) {
filledTexture.dispose();
}
lockedRingSprite = null;
}
clearLockedSatelliteStyleVisuals();
}
export function updateLockedRingPosition(position) {
if (!position) return;
const presentationMode = getSatellitePresentationMode(
getLockedSatelliteProperties() || {},
);
const hasStyleVisual =
presentationMode === SATELLITE_PRESENTATION_MODES.STARLINK_GROUND_FOOTPRINT
? Boolean(lockedGroundFootprintMesh)
: presentationMode === SATELLITE_PRESENTATION_MODES.IRIDIUM_SPOT_BEAMS
? Boolean(lockedIridiumFootprintMesh)
: Boolean(lockedDotSprite && lockedHaloMesh);
if (!lockedRingSprite || !hasStyleVisual) {
showHoverRing(position, true);
}
if (lockedRingSprite) {
const isHovered = isLockedSatelliteHovered();
updateLockedMarkerVisual(isHovered);
lockedRingSprite.position.copy(position);
const ringPulse = getBreathingPulse(breathingPhase);
const breathScale =
1 +
(ringPulse * 2 - 1) * SATELLITE_CONFIG.breathingScaleAmplitude;
const markerSize = isHovered
? SATELLITE_CONFIG.ringSize * LOCKED_RING_HOVER_SCALE
: SATELLITE_CONFIG.ringSize * LOCKED_RING_IDLE_SCALE;
lockedRingSprite.scale.set(
markerSize * breathScale,
markerSize * breathScale,
1,
);
lockedRingSprite.material.opacity =
isHovered
? SATELLITE_CONFIG.breathingOpacityMin +
ringPulse *
(SATELLITE_CONFIG.breathingOpacityMax -
SATELLITE_CONFIG.breathingOpacityMin)
: LOCKED_RING_IDLE_OPACITY;
}
if (lockedDotSprite) {
updateLockedDotWorldTransform(position);
const dotPulse = getBreathingPulse(breathingPhase);
const dotBreathScale = 1 + (dotPulse * 2 - 1) * SATELLITE_CONFIG.dotBreathingScaleAmplitude;
lockedDotSprite.scale.multiplyScalar(dotBreathScale);
lockedDotSprite.material.opacity =
SATELLITE_CONFIG.dotOpacityMin +
dotPulse *
(SATELLITE_CONFIG.dotOpacityMax - SATELLITE_CONFIG.dotOpacityMin);
}
if (lockedHaloMesh) {
updateLockedHaloWorldTransform(position);
const haloPulse = getBreathingPulse(breathingPhase);
const pulseScale =
1 +
(haloPulse * 2 - 1) * SATELLITE_CONFIG.dotBreathingScaleAmplitude * 0.32;
lockedHaloMesh.scale.multiplyScalar(pulseScale);
lockedHaloMesh.material.uniforms.uOpacity.value =
LOCKED_HALO_BASE_OPACITY * (0.9 + haloPulse * 0.16);
}
if (lockedGroundFootprintMesh) {
updateGroundFootprintTransform(position);
}
if (lockedIridiumFootprintMesh) {
updateIridiumReservedStyle(position);
}
}
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);
updateSatellitePointVisibilityAttributes();
break;
case "locked":
hideHoverRings();
showHoverRing(position, true);
updateSatellitePointVisibilityAttributes();
break;
case "none":
hoveredSatelliteIndex = null;
hideHoverRings();
hideLockedRing();
updateSatellitePointVisibilityAttributes();
break;
}
}
function applyDimMaterialState(isDimmed) {
if (satellitePoints) {
const opacity = isDimmed ? DIMMED_SATELLITE_POINT_OPACITY : 0.9;
if (satellitePoints.material.uniforms?.opacity) {
satellitePoints.material.uniforms.opacity.value = opacity;
} else {
satellitePoints.material.opacity = opacity;
}
}
if (satelliteBackdropPoints) {
const opacity = isDimmed ? DIMMED_SATELLITE_BACKDROP_OPACITY : 0.42;
if (satelliteBackdropPoints.material.uniforms?.opacity) {
satelliteBackdropPoints.material.uniforms.opacity.value = opacity;
} else {
satelliteBackdropPoints.material.opacity = opacity;
}
}
}
export function clearRelatedSatelliteHighlights() {
relatedSatelliteSprites.forEach((item) => {
if (item.sprite) {
disposeObject3D(item.sprite);
}
});
relatedSatelliteSprites = [];
highlightedSatelliteIndices = null;
applyDimMaterialState(false);
}
export function highlightRelatedSatellites(indices, color = "#7dd3fc") {
clearRelatedSatelliteHighlights();
if (!Array.isArray(indices) || indices.length === 0) return;
highlightedSatelliteIndices = new Set(indices);
applyDimMaterialState(true);
indices.forEach((index) => {
const pos = satellitePositions?.[index]?.current;
if (!pos) return;
const sprite = createRelatedSatelliteSprite(pos);
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();
const fixedSiderealTime = gstime(now);
for (let i = 0; i <= samples; i++) {
const time = new Date(now.getTime() + i * sampleInterval * 1000);
const pos = computeSatelliteInertialOrbitPosition(
satellite,
time,
fixedSiderealTime,
);
if (pos) points.push(pos);
}
if (points.length < samples * 0.5) {
return calculateFallbackPredictedOrbit(satellite, samples);
}
closeOrbitLoop(points);
return points;
}
function closeOrbitLoop(points) {
if (points.length > 2) {
points.push(points[0].clone());
}
}
function calculateFallbackPredictedOrbit(satellite, samples) {
const points = [];
const radius =
CONFIG.earthRadius + SATELLITE_CONFIG.fallbackAltitudeOffset;
const inclinationRad = THREE.MathUtils.degToRad(
satellite.properties?.inclination ?? 53,
);
const raanRad = THREE.MathUtils.degToRad(satellite.properties?.raan ?? 0);
const cosRaan = Math.cos(raanRad);
const sinRaan = Math.sin(raanRad);
const cosInclination = Math.cos(inclinationRad);
const sinInclination = Math.sin(inclinationRad);
for (let i = 0; i <= samples; i++) {
const argument = (i / samples) * Math.PI * 2;
const cosArgument = Math.cos(argument);
const sinArgument = Math.sin(argument);
const ecfX =
radius *
(cosRaan * cosArgument -
sinRaan * sinArgument * cosInclination);
const ecfY =
radius *
(sinRaan * cosArgument +
cosRaan * sinArgument * cosInclination);
const ecfZ = radius * sinArgument * sinInclination;
points.push(new THREE.Vector3(ecfX, ecfZ, -ecfY));
}
closeOrbitLoop(points);
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,
depthTest: true,
depthWrite: false,
});
predictedOrbitLine = new THREE.Line(geometry, material);
predictedOrbitLine.renderOrder = SATELLITE_CONFIG.overlayRenderOrder;
earthObjRef.add(predictedOrbitLine);
}
export function hidePredictedOrbit() {
if (predictedOrbitLine) {
disposeObject3D(predictedOrbitLine);
predictedOrbitLine = null;
}
}
export function clearSatelliteData() {
satelliteData = [];
satelliteSatrecCache = new Map();
selectedSatellite = null;
lockedSatelliteIndex = null;
hoveredSatelliteIndex = null;
positionUpdateAccumulator = 0;
breathingPhase = 0;
satellitePositions.forEach((position) => {
position.current.set(0, 0, 0);
});
resetSatelliteTrailState();
if (satellitePoints) {
const positionAttr = satellitePoints.geometry.attributes.position;
const colorAttr = satellitePoints.geometry.attributes.color;
const alphaAttr = satellitePoints.geometry.attributes.alpha;
if (positionAttr?.array) {
positionAttr.array.fill(0);
positionAttr.needsUpdate = true;
}
if (colorAttr?.array) {
colorAttr.array.fill(0);
colorAttr.needsUpdate = true;
}
if (alphaAttr?.array) {
alphaAttr.array.fill(0);
alphaAttr.needsUpdate = true;
}
satellitePoints.geometry.setDrawRange(0, 0);
}
if (satelliteBackdropPoints) {
const backdropPositionAttr =
satelliteBackdropPoints.geometry.attributes.position;
const backdropAlphaAttr =
satelliteBackdropPoints.geometry.attributes.alpha;
if (backdropPositionAttr?.array) {
backdropPositionAttr.array.fill(0);
backdropPositionAttr.needsUpdate = true;
}
if (backdropAlphaAttr?.array) {
backdropAlphaAttr.array.fill(0);
backdropAlphaAttr.needsUpdate = true;
}
satelliteBackdropPoints.geometry.setDrawRange(0, 0);
}
clearSatelliteTrailGeometry();
hideHoverRings();
hideLockedRing();
hidePredictedOrbit();
clearRelatedSatelliteHighlights();
}
export function resetSatelliteState() {
clearSatelliteData();
if (satelliteBackdropPoints) {
disposeObject3D(satelliteBackdropPoints);
satelliteBackdropPoints = null;
}
if (satellitePoints) {
disposeObject3D(satellitePoints);
satellitePoints = null;
}
if (satelliteTrails) {
disposeObject3D(satelliteTrails);
satelliteTrails = null;
}
satellitePositions = [];
satelliteCapacity = 0;
satelliteSatrecCache = new Map();
showSatellites = false;
showTrails = true;
}