2859 lines
87 KiB
JavaScript
2859 lines
87 KiB
JavaScript
// 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(),
|
||
currentTime: null,
|
||
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);
|
||
satellitePositions[i].currentTime = adjustedTime;
|
||
|
||
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 startTime = getSelectedSatellitePositionState(satellite)?.currentTime || new Date();
|
||
const fixedSiderealTime = gstime(startTime);
|
||
|
||
for (let i = 0; i <= samples; i++) {
|
||
const time = new Date(startTime.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;
|
||
}
|
||
|
||
function getSelectedSatellitePositionState(satellite) {
|
||
if (selectedSatellite === null || !satellitePositions?.[selectedSatellite]) {
|
||
return null;
|
||
}
|
||
|
||
const selectedData = satelliteData?.[selectedSatellite];
|
||
const selectedNoradId = selectedData?.properties?.norad_cat_id;
|
||
const targetNoradId = satellite?.properties?.norad_cat_id;
|
||
if (selectedData !== satellite && selectedNoradId !== targetNoradId) {
|
||
return null;
|
||
}
|
||
|
||
return satellitePositions[selectedSatellite];
|
||
}
|
||
|
||
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;
|
||
}
|