// satellites.js - Satellite visualization module with real SGP4 positions and animations import * as THREE from "three"; import { twoline2satrec, propagate } from "satellite.js"; import { CONFIG, SATELLITE_CONFIG } from "./constants.js"; let satellitePoints = null; let satelliteTrails = null; let satelliteData = []; let showSatellites = false; let showTrails = true; let selectedSatellite = null; let satellitePositions = []; let hoverRingSprite = null; let lockedRingSprite = null; let lockedDotSprite = null; let predictedOrbitLine = null; let earthObjRef = null; let sceneRef = null; let cameraRef = null; let lockedSatelliteIndex = null; let hoveredSatelliteIndex = null; let positionUpdateAccumulator = 0; let satelliteCapacity = 0; const TRAIL_LENGTH = SATELLITE_CONFIG.trailLength; const DOT_TEXTURE_SIZE = 32; const POSITION_UPDATE_INTERVAL_MS = 250; const scratchWorldSatellitePosition = new THREE.Vector3(); const scratchToCamera = new THREE.Vector3(); const scratchToSatellite = new THREE.Vector3(); export let breathingPhase = 0; export function updateBreathingPhase(deltaTime = 16) { breathingPhase += SATELLITE_CONFIG.breathingSpeed * (deltaTime / 16); } function disposeMaterial(material) { if (!material) return; if (Array.isArray(material)) { material.forEach(disposeMaterial); return; } if (material.map) { material.map.dispose(); } material.dispose(); } function disposeObject3D(object, parent = earthObjRef) { if (!object) return; if (parent) { parent.remove(object); } else if (object.parent) { object.parent.remove(object); } if (object.geometry) { object.geometry.dispose(); } if (object.material) { disposeMaterial(object.material); } } function createDotTexture() { const canvas = document.createElement("canvas"); canvas.width = DOT_TEXTURE_SIZE; canvas.height = DOT_TEXTURE_SIZE; const ctx = canvas.getContext("2d"); const center = DOT_TEXTURE_SIZE / 2; const radius = center - 2; const gradient = ctx.createRadialGradient( center, center, 0, center, center, radius, ); gradient.addColorStop(0, "rgba(255, 255, 255, 1)"); gradient.addColorStop(0.5, "rgba(255, 255, 255, 0.8)"); gradient.addColorStop(1, "rgba(255, 255, 255, 0)"); ctx.fillStyle = gradient; ctx.beginPath(); ctx.arc(center, center, radius, 0, Math.PI * 2); ctx.fill(); const texture = new THREE.CanvasTexture(canvas); texture.needsUpdate = true; return texture; } function createRingTexture(innerRadius, outerRadius, color = "#ffffff") { const size = DOT_TEXTURE_SIZE * 2; const canvas = document.createElement("canvas"); canvas.width = size; canvas.height = size; const ctx = canvas.getContext("2d"); const center = size / 2; ctx.strokeStyle = color; ctx.lineWidth = 3; ctx.beginPath(); ctx.arc(center, center, (innerRadius + outerRadius) / 2, 0, Math.PI * 2); ctx.stroke(); const texture = new THREE.CanvasTexture(canvas); texture.needsUpdate = true; return texture; } export function createSatellites(scene, earthObj) { initSatelliteScene(scene, earthObj); const dotTexture = createDotTexture(); const pointsGeometry = new THREE.BufferGeometry(); const pointsMaterial = new THREE.PointsMaterial({ size: SATELLITE_CONFIG.dotSize, map: dotTexture, vertexColors: true, transparent: true, opacity: 0.9, sizeAttenuation: false, alphaTest: 0.1, }); satellitePoints = new THREE.Points(pointsGeometry, pointsMaterial); satellitePoints.visible = false; satellitePoints.userData = { type: "satellitePoints" }; const originalScale = { x: 1, y: 1, z: 1 }; satellitePoints.onBeforeRender = () => { if (earthObj && earthObj.scale.x !== 1) { satellitePoints.scale.set( originalScale.x / earthObj.scale.x, originalScale.y / earthObj.scale.y, originalScale.z / earthObj.scale.z, ); } else { satellitePoints.scale.set( originalScale.x, originalScale.y, originalScale.z, ); } }; earthObj.add(satellitePoints); const trailGeometry = new THREE.BufferGeometry(); const trailMaterial = new THREE.LineBasicMaterial({ vertexColors: true, transparent: true, opacity: 0.3, blending: THREE.AdditiveBlending, }); satelliteTrails = new THREE.LineSegments(trailGeometry, trailMaterial); satelliteTrails.visible = false; satelliteTrails.userData = { type: "satelliteTrails" }; earthObj.add(satelliteTrails); ensureSatelliteCapacity(0); positionUpdateAccumulator = POSITION_UPDATE_INTERVAL_MS; return satellitePoints; } function getRequestedSatelliteLimit() { return SATELLITE_CONFIG.maxCount < 0 ? null : SATELLITE_CONFIG.maxCount; } function createSatellitePositionState() { return { current: new THREE.Vector3(), trail: [], trailIndex: 0, trailCount: 0, }; } function ensureSatelliteCapacity(count) { if (!satellitePoints || !satelliteTrails) return; const nextCapacity = Math.max(count, 0); if (nextCapacity === satelliteCapacity) return; const positions = new Float32Array(nextCapacity * 3); const colors = new Float32Array(nextCapacity * 3); satellitePoints.geometry.setAttribute( "position", new THREE.BufferAttribute(positions, 3), ); satellitePoints.geometry.setAttribute( "color", new THREE.BufferAttribute(colors, 3), ); satellitePoints.geometry.setDrawRange(0, 0); const trailPositions = new Float32Array(nextCapacity * TRAIL_LENGTH * 3); const trailColors = new Float32Array(nextCapacity * TRAIL_LENGTH * 3); satelliteTrails.geometry.setAttribute( "position", new THREE.BufferAttribute(trailPositions, 3), ); satelliteTrails.geometry.setAttribute( "color", new THREE.BufferAttribute(trailColors, 3), ); satellitePositions = Array.from( { length: nextCapacity }, createSatellitePositionState, ); satelliteCapacity = nextCapacity; } function computeSatellitePosition(satellite, time) { try { const props = satellite.properties; if (!props || !props.norad_cat_id) { return null; } const satrec = buildSatrecFromProperties(props, time); if (!satrec || satrec.error) { return null; } const positionAndVelocity = propagate(satrec, time); if (!positionAndVelocity || !positionAndVelocity.position) { return null; } const x = positionAndVelocity.position.x; const y = positionAndVelocity.position.y; const z = positionAndVelocity.position.z; if (!Number.isFinite(x) || !Number.isFinite(y) || !Number.isFinite(z)) { return null; } const r = Math.sqrt(x * x + y * y + z * z); const displayRadius = CONFIG.earthRadius * 1.05; const scale = displayRadius / r; return new THREE.Vector3(x * scale, y * scale, z * scale); } catch (error) { return null; } } function buildSatrecFromProperties(props, fallbackTime) { if (props.tle_line1 && props.tle_line2) { // Prefer source-provided TLE lines so the client does not need to rebuild them. const satrec = twoline2satrec(props.tle_line1, props.tle_line2); if (!satrec.error) { return satrec; } } const tleLines = buildTleLinesFromElements(props, fallbackTime); if (!tleLines) { return null; } return twoline2satrec(tleLines.line1, tleLines.line2); } function computeTleChecksum(line) { let sum = 0; for (const char of line.slice(0, 68)) { if (char >= "0" && char <= "9") { sum += Number(char); } else if (char === "-") { sum += 1; } } return String(sum % 10); } function buildTleLinesFromElements(props, fallbackTime) { if (!props?.norad_cat_id) { return null; } const requiredValues = [ props.inclination, props.raan, props.eccentricity, props.arg_of_perigee, props.mean_anomaly, props.mean_motion, ]; if (requiredValues.some((value) => value === null || value === undefined)) { return null; } const epochDate = props.epoch && String(props.epoch).length >= 10 ? new Date(props.epoch) : fallbackTime; if (Number.isNaN(epochDate.getTime())) { return null; } const epochYear = epochDate.getUTCFullYear() % 100; const startOfYear = new Date(Date.UTC(epochDate.getUTCFullYear(), 0, 1)); const dayOfYear = Math.floor((epochDate - startOfYear) / 86400000) + 1; const msOfDay = epochDate.getUTCHours() * 3600000 + epochDate.getUTCMinutes() * 60000 + epochDate.getUTCSeconds() * 1000 + epochDate.getUTCMilliseconds(); const dayFraction = msOfDay / 86400000; const epochStr = String(epochYear).padStart(2, "0") + String(dayOfYear).padStart(3, "0") + dayFraction.toFixed(8).slice(1); const eccentricityDigits = Math.round(Number(props.eccentricity) * 1e7) .toString() .padStart(7, "0"); // Keep a local fallback for historical rows that do not have stored TLE lines yet. const line1Core = `1 ${String(props.norad_cat_id).padStart(5, "0")}U 00001A ${epochStr} .00000000 00000-0 00000-0 0 999`; const line2Core = `2 ${String(props.norad_cat_id).padStart(5, "0")} ${Number( props.inclination, ) .toFixed(4) .padStart( 8, )} ${Number(props.raan).toFixed(4).padStart(8)} ${eccentricityDigits} ${Number( props.arg_of_perigee, ) .toFixed(4) .padStart(8)} ${Number(props.mean_anomaly).toFixed(4).padStart(8)} ${Number( props.mean_motion, ) .toFixed(8) .padStart(11)}00000`; return { line1: line1Core + computeTleChecksum(line1Core), line2: line2Core + computeTleChecksum(line2Core), }; } function generateFallbackPosition(satellite, index, total) { const radius = CONFIG.earthRadius + 5; const noradId = satellite.properties?.norad_cat_id || index; const inclination = satellite.properties?.inclination || 53; const raan = satellite.properties?.raan || 0; const meanAnomaly = satellite.properties?.mean_anomaly || 0; const hash = String(noradId) .split("") .reduce((a, b) => a + b.charCodeAt(0), 0); const randomOffset = (hash % 1000) / 1000; const normalizedIndex = index / total; const theta = normalizedIndex * Math.PI * 2 * 10 + (raan * Math.PI) / 180; const phi = (inclination * Math.PI) / 180 + ((meanAnomaly * Math.PI) / 180) * 0.1; const adjustedPhi = Math.abs(phi % Math.PI); const adjustedTheta = theta + randomOffset * Math.PI * 2; const x = radius * Math.sin(adjustedPhi) * Math.cos(adjustedTheta); const y = radius * Math.cos(adjustedPhi); const z = radius * Math.sin(adjustedPhi) * Math.sin(adjustedTheta); return new THREE.Vector3(x, y, z); } export async function loadSatellites() { const limit = getRequestedSatelliteLimit(); const url = new URL(SATELLITE_CONFIG.apiPath, window.location.origin); if (limit !== null) { url.searchParams.set("limit", String(limit)); } const response = await fetch(url.toString()); if (!response.ok) { throw new Error(`卫星接口返回 HTTP ${response.status}`); } const data = await response.json(); satelliteData = data.features || []; ensureSatelliteCapacity(satelliteData.length); positionUpdateAccumulator = POSITION_UPDATE_INTERVAL_MS; return satelliteData.length; } export function updateSatellitePositions(deltaTime = 0, force = false) { if (!satellitePoints || satelliteData.length === 0) return; const shouldUpdateTrails = showSatellites || showTrails || lockedSatelliteIndex !== null; positionUpdateAccumulator += deltaTime; if (!force && positionUpdateAccumulator < POSITION_UPDATE_INTERVAL_MS) { return; } const elapsedMs = Math.max( positionUpdateAccumulator, POSITION_UPDATE_INTERVAL_MS, ); positionUpdateAccumulator = 0; const positions = satellitePoints.geometry.attributes.position.array; const colors = satellitePoints.geometry.attributes.color.array; const trailPositions = satelliteTrails.geometry.attributes.position.array; const trailColors = satelliteTrails.geometry.attributes.color.array; const baseTime = new Date(Date.now() + elapsedMs); const count = Math.min(satelliteData.length, satelliteCapacity); for (let i = 0; i < count; i++) { const satellite = satelliteData[i]; const props = satellite.properties; const timeOffset = (i / count) * 2 * Math.PI * 0.1; const adjustedTime = new Date( baseTime.getTime() + timeOffset * 1000 * 60 * 10, ); let pos = computeSatellitePosition(satellite, adjustedTime); if (!pos) { pos = generateFallbackPosition(satellite, i, count); } satellitePositions[i].current.copy(pos); if (shouldUpdateTrails && i !== lockedSatelliteIndex) { const satPos = satellitePositions[i]; satPos.trail[satPos.trailIndex] = pos.clone(); satPos.trailIndex = (satPos.trailIndex + 1) % TRAIL_LENGTH; if (satPos.trailCount < TRAIL_LENGTH) satPos.trailCount++; } positions[i * 3] = pos.x; positions[i * 3 + 1] = pos.y; positions[i * 3 + 2] = pos.z; const inclination = props?.inclination || 53; const name = props?.name || ""; const isStarlink = name.includes("STARLINK"); const isGeo = inclination > 20 && inclination < 30; const isIridium = name.includes("IRIDIUM"); let r; let g; let b; if (isStarlink) { r = 0.0; g = 0.9; b = 1.0; } else if (isGeo) { r = 1.0; g = 0.8; b = 0.0; } else if (isIridium) { r = 1.0; g = 0.5; b = 0.0; } else if (inclination > 50 && inclination < 70) { r = 0.0; g = 1.0; b = 0.3; } else { r = 1.0; g = 1.0; b = 1.0; } colors[i * 3] = r; colors[i * 3 + 1] = g; colors[i * 3 + 2] = b; const satPosition = satellitePositions[i]; for (let j = 0; j < TRAIL_LENGTH; j++) { const trailIdx = (i * TRAIL_LENGTH + j) * 3; if (j < satPosition.trailCount) { const idx = (satPosition.trailIndex - satPosition.trailCount + j + TRAIL_LENGTH) % TRAIL_LENGTH; const trailPoint = satPosition.trail[idx]; if (trailPoint) { trailPositions[trailIdx] = trailPoint.x; trailPositions[trailIdx + 1] = trailPoint.y; trailPositions[trailIdx + 2] = trailPoint.z; const alpha = (j + 1) / satPosition.trailCount; trailColors[trailIdx] = r * alpha; trailColors[trailIdx + 1] = g * alpha; trailColors[trailIdx + 2] = b * alpha; continue; } } trailPositions[trailIdx] = pos.x; trailPositions[trailIdx + 1] = pos.y; trailPositions[trailIdx + 2] = pos.z; trailColors[trailIdx] = 0; trailColors[trailIdx + 1] = 0; trailColors[trailIdx + 2] = 0; } } for (let i = count; i < satelliteCapacity; i++) { positions[i * 3] = 0; positions[i * 3 + 1] = 0; positions[i * 3 + 2] = 0; for (let j = 0; j < TRAIL_LENGTH; j++) { const trailIdx = (i * TRAIL_LENGTH + j) * 3; trailPositions[trailIdx] = 0; trailPositions[trailIdx + 1] = 0; trailPositions[trailIdx + 2] = 0; } } satellitePoints.geometry.attributes.position.needsUpdate = true; satellitePoints.geometry.attributes.color.needsUpdate = true; satellitePoints.geometry.setDrawRange(0, count); satelliteTrails.geometry.attributes.position.needsUpdate = true; satelliteTrails.geometry.attributes.color.needsUpdate = true; // Keep the hover ring synced with the propagated satellite position even // when the pointer stays still and no new hover event is emitted. if ( hoveredSatelliteIndex !== null && hoveredSatelliteIndex >= 0 && hoveredSatelliteIndex < count && hoveredSatelliteIndex !== lockedSatelliteIndex ) { updateHoverRingPosition(satellitePositions[hoveredSatelliteIndex].current); } } export function toggleSatellites(visible) { showSatellites = visible; if (satellitePoints) { satellitePoints.visible = visible; } if (satelliteTrails) { satelliteTrails.visible = visible && showTrails; } } export function toggleTrails(visible) { showTrails = visible; if (satelliteTrails) { satelliteTrails.visible = visible && showSatellites; } } export function getShowSatellites() { return showSatellites; } export function getSatelliteCount() { return satelliteData.length; } export function getSatelliteAt(index) { if (index >= 0 && index < satelliteData.length) { return satelliteData[index]; } return null; } export function getSatelliteData() { return satelliteData; } export function selectSatellite(index) { selectedSatellite = index; return getSatelliteAt(index); } export function getSatellitePoints() { return satellitePoints; } export function getSatellitePositions() { return satellitePositions; } export function setSatelliteCamera(camera) { cameraRef = camera; } export function setLockedSatelliteIndex(index) { lockedSatelliteIndex = index; } export function setHoveredSatelliteIndex(index) { hoveredSatelliteIndex = index; } export function isSatelliteFrontFacing(index, camera = cameraRef) { if (!earthObjRef || !camera) return true; if (!satellitePositions || !satellitePositions[index]) return true; const satPos = satellitePositions[index].current; if (!satPos) return true; scratchWorldSatellitePosition .copy(satPos) .applyMatrix4(earthObjRef.matrixWorld); scratchToCamera.subVectors(camera.position, earthObjRef.position).normalize(); scratchToSatellite .subVectors(scratchWorldSatellitePosition, earthObjRef.position) .normalize(); return scratchToCamera.dot(scratchToSatellite) > 0; } function createBrighterDotCanvas() { const size = DOT_TEXTURE_SIZE * 2; const canvas = document.createElement("canvas"); canvas.width = size; canvas.height = size; const ctx = canvas.getContext("2d"); const center = size / 2; const gradient = ctx.createRadialGradient( center, center, 0, center, center, center, ); gradient.addColorStop(0, "rgba(255, 255, 200, 1)"); gradient.addColorStop(0.3, "rgba(255, 220, 100, 0.9)"); gradient.addColorStop(0.7, "rgba(255, 180, 50, 0.5)"); gradient.addColorStop(1, "rgba(255, 150, 0, 0)"); ctx.fillStyle = gradient; ctx.fillRect(0, 0, size, size); return canvas; } function createRingSprite(position, isLocked = false) { if (!earthObjRef) return null; const ringTexture = createRingTexture( 8, 12, isLocked ? "#ffcc00" : "#ffffff", ); const spriteMaterial = new THREE.SpriteMaterial({ map: ringTexture, transparent: true, opacity: 0.8, depthTest: false, sizeAttenuation: false, }); const sprite = new THREE.Sprite(spriteMaterial); sprite.position.copy(position); sprite.scale.set(SATELLITE_CONFIG.ringSize, SATELLITE_CONFIG.ringSize, 1); earthObjRef.add(sprite); return sprite; } export function showHoverRing(position, isLocked = false) { if (!earthObjRef || !position) return null; if (isLocked) { hideLockedRing(); lockedRingSprite = createRingSprite(position, true); const dotCanvas = createBrighterDotCanvas(); const dotTexture = new THREE.CanvasTexture(dotCanvas); const dotMaterial = new THREE.SpriteMaterial({ map: dotTexture, transparent: true, opacity: 1.0, depthTest: false, }); lockedDotSprite = new THREE.Sprite(dotMaterial); lockedDotSprite.position.copy(position); lockedDotSprite.scale.set(4, 4, 1); earthObjRef.add(lockedDotSprite); return lockedRingSprite; } hideHoverRings(); hoverRingSprite = createRingSprite(position, false); return hoverRingSprite; } export function hideHoverRings() { if (hoverRingSprite) { disposeObject3D(hoverRingSprite); hoverRingSprite = null; } } export function hideLockedRing() { if (lockedRingSprite) { disposeObject3D(lockedRingSprite); lockedRingSprite = null; } if (lockedDotSprite) { disposeObject3D(lockedDotSprite); lockedDotSprite = null; } } export function updateLockedRingPosition(position) { if (!position) return; if (lockedRingSprite) { lockedRingSprite.position.copy(position); const breathScale = 1 + Math.sin(breathingPhase) * SATELLITE_CONFIG.breathingScaleAmplitude; lockedRingSprite.scale.set( SATELLITE_CONFIG.ringSize * breathScale, SATELLITE_CONFIG.ringSize * breathScale, 1, ); lockedRingSprite.material.opacity = SATELLITE_CONFIG.breathingOpacityMin + Math.sin(breathingPhase) * (SATELLITE_CONFIG.breathingOpacityMax - SATELLITE_CONFIG.breathingOpacityMin); } if (lockedDotSprite) { lockedDotSprite.position.copy(position); const dotBreathScale = 1 + Math.sin(breathingPhase) * SATELLITE_CONFIG.dotBreathingScaleAmplitude; lockedDotSprite.scale.set(4 * dotBreathScale, 4 * dotBreathScale, 1); lockedDotSprite.material.opacity = SATELLITE_CONFIG.dotOpacityMin + Math.sin(breathingPhase) * (SATELLITE_CONFIG.dotOpacityMax - SATELLITE_CONFIG.dotOpacityMin); } } export function updateHoverRingPosition(position) { if (hoverRingSprite && position) { hoverRingSprite.position.copy(position); hoverRingSprite.scale.set( SATELLITE_CONFIG.ringSize, SATELLITE_CONFIG.ringSize, 1, ); } } export function setSatelliteRingState(index, state, position) { switch (state) { case "hover": hoveredSatelliteIndex = index; hideHoverRings(); showHoverRing(position, false); break; case "locked": hoveredSatelliteIndex = null; hideHoverRings(); showHoverRing(position, true); break; case "none": hoveredSatelliteIndex = null; hideHoverRings(); hideLockedRing(); break; } } export function initSatelliteScene(scene, earth) { sceneRef = scene; earthObjRef = earth; } function calculateOrbitalPeriod(meanMotion) { return 86400 / meanMotion; } function calculatePredictedOrbit( satellite, periodSeconds, sampleInterval = 10, ) { const points = []; const samples = Math.ceil(periodSeconds / sampleInterval); const now = new Date(); for (let i = 0; i <= samples; i++) { const time = new Date(now.getTime() + i * sampleInterval * 1000); const pos = computeSatellitePosition(satellite, time); if (pos) points.push(pos); } if (points.length < samples * 0.5) { points.length = 0; const radius = CONFIG.earthRadius + 5; const inclination = satellite.properties?.inclination || 53; const raan = satellite.properties?.raan || 0; for (let i = 0; i <= samples; i++) { const theta = (i / samples) * Math.PI * 2; const phi = (inclination * Math.PI) / 180; const x = radius * Math.sin(phi) * Math.cos(theta + (raan * Math.PI) / 180); const y = radius * Math.cos(phi); const z = radius * Math.sin(phi) * Math.sin(theta + (raan * Math.PI) / 180); points.push(new THREE.Vector3(x, y, z)); } } return points; } export function showPredictedOrbit(satellite) { hidePredictedOrbit(); if (!earthObjRef) return; const meanMotion = satellite.properties?.mean_motion || 15; const periodSeconds = calculateOrbitalPeriod(meanMotion); const points = calculatePredictedOrbit(satellite, periodSeconds); if (points.length < 2) return; const positions = new Float32Array(points.length * 3); const colors = new Float32Array(points.length * 3); for (let i = 0; i < points.length; i++) { positions[i * 3] = points[i].x; positions[i * 3 + 1] = points[i].y; positions[i * 3 + 2] = points[i].z; const t = i / (points.length - 1); colors[i * 3] = 1 - t * 0.4; colors[i * 3 + 1] = 1 - t * 0.6; colors[i * 3 + 2] = t; } const geometry = new THREE.BufferGeometry(); geometry.setAttribute("position", new THREE.BufferAttribute(positions, 3)); geometry.setAttribute("color", new THREE.BufferAttribute(colors, 3)); const material = new THREE.LineBasicMaterial({ vertexColors: true, transparent: true, opacity: 0.8, blending: THREE.AdditiveBlending, }); predictedOrbitLine = new THREE.Line(geometry, material); earthObjRef.add(predictedOrbitLine); } export function hidePredictedOrbit() { if (predictedOrbitLine) { disposeObject3D(predictedOrbitLine); predictedOrbitLine = null; } } export function clearSatelliteData() { satelliteData = []; selectedSatellite = null; lockedSatelliteIndex = null; hoveredSatelliteIndex = null; positionUpdateAccumulator = 0; satellitePositions.forEach((position) => { position.current.set(0, 0, 0); position.trail = []; position.trailIndex = 0; position.trailCount = 0; }); if (satellitePoints) { const positionAttr = satellitePoints.geometry.attributes.position; const colorAttr = satellitePoints.geometry.attributes.color; if (positionAttr?.array) { positionAttr.array.fill(0); positionAttr.needsUpdate = true; } if (colorAttr?.array) { colorAttr.array.fill(0); colorAttr.needsUpdate = true; } satellitePoints.geometry.setDrawRange(0, 0); } if (satelliteTrails) { const trailPositionAttr = satelliteTrails.geometry.attributes.position; const trailColorAttr = satelliteTrails.geometry.attributes.color; if (trailPositionAttr?.array) { trailPositionAttr.array.fill(0); trailPositionAttr.needsUpdate = true; } if (trailColorAttr?.array) { trailColorAttr.array.fill(0); trailColorAttr.needsUpdate = true; } } hideHoverRings(); hideLockedRing(); hidePredictedOrbit(); } export function resetSatelliteState() { clearSatelliteData(); if (satellitePoints) { disposeObject3D(satellitePoints); satellitePoints = null; } if (satelliteTrails) { disposeObject3D(satelliteTrails); satelliteTrails = null; } satellitePositions = []; satelliteCapacity = 0; showSatellites = false; showTrails = true; }