// satellites.js - Satellite visualization module with real SGP4 positions and animations import * as THREE from "three"; import { twoline2satrec, propagate } from "satellite.js"; import { CONFIG, SATELLITE_CONFIG } from "./constants.js"; import { latLonToVector3 } from "./utils.js"; let satellitePoints = null; let 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 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(); const TRAIL_LENGTH = SATELLITE_CONFIG.trailLength; const DOT_TEXTURE_SIZE = 32; const POSITION_UPDATE_INTERVAL_MS = 250; const scratchWorldSatellitePosition = new THREE.Vector3(); const scratchToCamera = new THREE.Vector3(); const scratchToSatellite = new THREE.Vector3(); export let breathingPhase = 0; const SATELLITE_LEGEND_RULES = [ { key: "equatorial", label: "赤道轨道(0-30°)", color: "#ff3333", match: (props) => { const inclination = Number(props?.inclination ?? 0); return inclination >= 0 && inclination < 30; }, }, { key: "low", label: "低倾角轨道(30-60°)", color: "#ff9933", match: (props) => { const inclination = Number(props?.inclination ?? 0); return inclination >= 30 && inclination < 60; }, }, { key: "medium", label: "中倾角轨道(60-90°)", color: "#ffff33", match: (props) => { const inclination = Number(props?.inclination ?? 0); return inclination >= 60 && inclination < 90; }, }, { key: "high", label: "高倾角轨道(90-120°)", color: "#33ff33", match: (props) => { const inclination = Number(props?.inclination ?? 0); return inclination >= 90 && inclination < 120; }, }, { key: "retrograde", label: "逆行轨道(120-180°)", color: "#3333ff", match: (props) => { const inclination = Number(props?.inclination ?? 0); return inclination >= 120 && inclination <= 180; }, }, { key: "other", label: "其他", color: "#d7e2f4", match: () => true, }, ]; const SATELLITE_RULE_COLOR_CACHE = new Map(); function getSatelliteLegendRule(props = {}) { return ( SATELLITE_LEGEND_RULES.find((rule) => rule.match(props)) || SATELLITE_LEGEND_RULES[SATELLITE_LEGEND_RULES.length - 1] ); } function getSatelliteRuleColor(rule) { if (!rule) { return { r: 1, g: 1, b: 1 }; } if (SATELLITE_RULE_COLOR_CACHE.has(rule.key)) { return SATELLITE_RULE_COLOR_CACHE.get(rule.key); } const color = new THREE.Color(rule.color); const rgb = { r: color.r, g: color.g, b: color.b }; SATELLITE_RULE_COLOR_CACHE.set(rule.key, rgb); return rgb; } export function updateBreathingPhase(deltaTime = 16) { breathingPhase += SATELLITE_CONFIG.breathingSpeed * (deltaTime / 16); } 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 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") { 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 backdropTexture = createBackdropDotTexture(); const pointsGeometry = new THREE.BufferGeometry(); const backdropGeometry = new THREE.BufferGeometry(); const backdropMaterial = new THREE.PointsMaterial({ size: SATELLITE_CONFIG.dotSize * 1.28, map: backdropTexture, color: 0x0b1626, transparent: true, opacity: 0.42, sizeAttenuation: false, alphaTest: 0.04, depthWrite: false, }); const pointsMaterial = new THREE.PointsMaterial({ size: SATELLITE_CONFIG.dotSize, map: dotTexture, vertexColors: true, transparent: true, opacity: 0.9, sizeAttenuation: false, alphaTest: 0.1, depthWrite: false, }); 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); 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(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 createSatellitePositionState() { return { current: new THREE.Vector3(), trail: [], trailIndex: 0, trailCount: 0, }; } 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 previousTrailPositions = satelliteTrails.geometry.attributes.position?.array || null; const previousTrailColors = satelliteTrails.geometry.attributes.color?.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); 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))); } satelliteBackdropPoints.geometry.setAttribute( "position", new THREE.BufferAttribute(backdropPositions, 3), ); 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.setDrawRange(0, Math.min(previousCapacity, nextCapacity)); const trailPositions = new Float32Array(nextCapacity * TRAIL_LENGTH * 3); const trailColors = new Float32Array(nextCapacity * TRAIL_LENGTH * 3); if (previousTrailPositions) { trailPositions.set( previousTrailPositions.subarray( 0, Math.min(previousTrailPositions.length, trailPositions.length), ), ); } if (previousTrailColors) { trailColors.set( previousTrailColors.subarray( 0, Math.min(previousTrailColors.length, trailColors.length), ), ); } satelliteTrails.geometry.setAttribute( "position", new THREE.BufferAttribute(trailPositions, 3), ); satelliteTrails.geometry.setAttribute( "color", new THREE.BufferAttribute(trailColors, 3), ); satellitePositions = Array.from({ length: nextCapacity }, (_, index) => { const previousState = previousSatellitePositions[index]; if (!previousState) { return createSatellitePositionState(); } return { current: previousState.current.clone(), trail: previousState.trail.slice(), trailIndex: previousState.trailIndex, trailCount: previousState.trailCount, }; }); satelliteCapacity = nextCapacity; } 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; } 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 + SATELLITE_CONFIG.displayAltitudeOffset; 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 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) { const radius = CONFIG.earthRadius + SATELLITE_CONFIG.displayAltitudeOffset; 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(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(); ensureSatelliteCapacity(satelliteData.length); positionUpdateAccumulator = POSITION_UPDATE_INTERVAL_MS; return { count: satelliteData.length, requestedLimit: limit, }; } export function updateSatellitePositions(deltaTime = 0, force = false) { if (!satellitePoints || !satelliteBackdropPoints || 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 backdropPositions = satelliteBackdropPoints.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; 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); if (highlightedSatelliteIndices !== null && !highlightedSatelliteIndices.has(i)) { const lum = r * 0.299 + g * 0.587 + b * 0.114; colors[i * 3] = lum * 0.75 + r * 0.25; colors[i * 3 + 1] = lum * 0.75 + g * 0.25; colors[i * 3 + 2] = lum * 0.75 + b * 0.25; } else { 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; backdropPositions[i * 3] = 0; backdropPositions[i * 3 + 1] = 0; backdropPositions[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); satelliteBackdropPoints.geometry.attributes.position.needsUpdate = true; satelliteBackdropPoints.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 (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 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) > SATELLITE_CONFIG.frontFacingDotThreshold ); } 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); sprite.renderOrder = SATELLITE_CONFIG.overlayRenderOrder; earthObjRef.add(sprite); return sprite; } function createRelatedSatelliteSprite(position, color = "#7dd3fc") { if (!earthObjRef) return null; const ringTexture = createRingTexture(7, 11, color); const spriteMaterial = new THREE.SpriteMaterial({ map: ringTexture, transparent: true, opacity: 0.55, depthTest: false, sizeAttenuation: false, }); const sprite = new THREE.Sprite(spriteMaterial); sprite.position.copy(position); sprite.scale.set(SATELLITE_CONFIG.ringSize * 0.8, SATELLITE_CONFIG.ringSize * 0.8, 1); sprite.renderOrder = SATELLITE_CONFIG.overlayRenderOrder; 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); lockedDotSprite.renderOrder = SATELLITE_CONFIG.overlayRenderOrder + 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 || !lockedDotSprite) { showHoverRing(position, true); } if (lockedRingSprite) { lockedRingSprite.position.copy(position); const ringPulse = getBreathingPulse(breathingPhase); const breathScale = 1 + (ringPulse * 2 - 1) * SATELLITE_CONFIG.breathingScaleAmplitude; lockedRingSprite.scale.set( SATELLITE_CONFIG.ringSize * breathScale, SATELLITE_CONFIG.ringSize * breathScale, 1, ); lockedRingSprite.material.opacity = SATELLITE_CONFIG.breathingOpacityMin + ringPulse * (SATELLITE_CONFIG.breathingOpacityMax - SATELLITE_CONFIG.breathingOpacityMin); } if (lockedDotSprite) { lockedDotSprite.position.copy(position); const dotPulse = getBreathingPulse(breathingPhase); const dotBreathScale = 1 + (dotPulse * 2 - 1) * SATELLITE_CONFIG.dotBreathingScaleAmplitude; lockedDotSprite.scale.set(4 * dotBreathScale, 4 * dotBreathScale, 1); lockedDotSprite.material.opacity = SATELLITE_CONFIG.dotOpacityMin + dotPulse * (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; } } function applyDimMaterialState(isDimmed) { if (satellitePoints) { satellitePoints.material.opacity = isDimmed ? 0.32 : 0.9; } if (satelliteBackdropPoints) { satelliteBackdropPoints.material.opacity = isDimmed ? 0.12 : 0.42; } } 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, color); if (!sprite) return; relatedSatelliteSprites.push({ index, sprite, color }); }); } export function updateRelatedSatelliteHighlights() { if (relatedSatelliteSprites.length === 0) return; relatedSatelliteSprites = relatedSatelliteSprites.filter((item) => { const pos = satellitePositions?.[item.index]?.current; if (!pos || !item.sprite) return false; item.sprite.position.copy(pos); return true; }); } export function getRelatedSatelliteIndicesForRegions( regions, { limit = 6, maxAngleDeg = 22 } = {}, ) { if (!Array.isArray(regions) || regions.length === 0 || satellitePositions.length === 0) { return []; } const regionVectors = regions .filter( (region) => typeof region?.latitude === "number" && typeof region?.longitude === "number", ) .map((region) => latLonToVector3(region.latitude, region.longitude, CONFIG.earthRadius + 1) .clone() .normalize(), ); if (regionVectors.length === 0) return []; const threshold = Math.cos((maxAngleDeg * Math.PI) / 180); const ranked = []; satellitePositions.forEach((item, index) => { const current = item?.current; if (!current || current.lengthSq() === 0) return; const satVector = current.clone().normalize(); let bestDot = -1; regionVectors.forEach((regionVector) => { bestDot = Math.max(bestDot, satVector.dot(regionVector)); }); if (bestDot >= threshold) { ranked.push({ index, score: bestDot }); } }); return ranked .sort((a, b) => b.score - a.score) .slice(0, limit) .map((item) => item.index); } export function initSatelliteScene(scene, earth) { sceneRef = scene; earthObjRef = earth; } function calculateOrbitalPeriod(meanMotion) { return 86400 / meanMotion; } function calculatePredictedOrbit( satellite, periodSeconds, sampleInterval = 10, ) { const points = []; const samples = Math.ceil(periodSeconds / sampleInterval); const now = new Date(); for (let i = 0; i <= samples; i++) { const time = new Date(now.getTime() + i * sampleInterval * 1000); const pos = computeSatellitePosition(satellite, time); if (pos) points.push(pos); } if (points.length < samples * 0.5) { points.length = 0; const radius = CONFIG.earthRadius + SATELLITE_CONFIG.displayAltitudeOffset; 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, 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); 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 (satelliteBackdropPoints) { const backdropPositionAttr = satelliteBackdropPoints.geometry.attributes.position; if (backdropPositionAttr?.array) { backdropPositionAttr.array.fill(0); backdropPositionAttr.needsUpdate = true; } satelliteBackdropPoints.geometry.setDrawRange(0, 0); } if (satelliteTrails) { const trailPositionAttr = satelliteTrails.geometry.attributes.position; const trailColorAttr = satelliteTrails.geometry.attributes.color; if (trailPositionAttr?.array) { trailPositionAttr.array.fill(0); trailPositionAttr.needsUpdate = true; } if (trailColorAttr?.array) { trailColorAttr.array.fill(0); trailColorAttr.needsUpdate = true; } } hideHoverRings(); hideLockedRing(); hidePredictedOrbit(); clearRelatedSatelliteHighlights(); } export function resetSatelliteState() { clearSatelliteData(); if (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; }