// satellites.js - Satellite visualization module with real SGP4 positions and animations import * as THREE from "three"; import { twoline2satrec, propagate } 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; 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 BACKGROUND_TRAIL_RESET_DELTA_MS = 2000; 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 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 createSatellitePositionState() { return { current: new THREE.Vector3(), trail: [], trailIndex: 0, trailCount: 0, }; } 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(); } return { current: previousState.current.clone(), trail: previousState.trail.slice(), trailIndex: previousState.trailIndex, trailCount: previousState.trailCount, }; }); 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; } 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, time = new Date()) { 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 rawMeanMotion = Number(satellite.properties?.mean_motion); const meanMotion = Number.isFinite(rawMeanMotion) && rawMeanMotion > 0 ? rawMeanMotion : FALLBACK_MIN_MEAN_MOTION + randomOffset * FALLBACK_MEAN_MOTION_SPREAD; const normalizedIndex = index / total; const elapsedDays = Number.isFinite(time?.getTime?.()) ? time.getTime() / FALLBACK_ORBIT_DAY_MS : Date.now() / FALLBACK_ORBIT_DAY_MS; const fallbackPhase = elapsedDays * meanMotion * Math.PI * 2; const theta = normalizedIndex * Math.PI * 2 * 10 + (raan * Math.PI) / 180 + fallbackPhase; const phi = (inclination * Math.PI) / 180 + ((meanAnomaly * Math.PI) / 180) * 0.1; const adjustedPhi = Math.abs(phi % Math.PI); const adjustedTheta = theta + randomOffset * Math.PI * 2; const x = radius * Math.sin(adjustedPhi) * Math.cos(adjustedTheta); const y = radius * Math.cos(adjustedPhi); const z = radius * Math.sin(adjustedPhi) * Math.sin(adjustedTheta); return new THREE.Vector3(x, y, z); } export async function loadSatellites(options = {}) { const limit = getRequestedSatelliteLimit(options.limit); const url = new URL(SATELLITE_CONFIG.apiPath, window.location.origin); if (limit !== null) { url.searchParams.set("limit", String(limit)); } const response = await fetch(url.toString()); if (!response.ok) { throw new Error(`卫星接口返回 HTTP ${response.status}`); } const data = await response.json(); satelliteData = data.features || []; satelliteSatrecCache = new Map(); resetSatelliteTrailState(); ensureSatelliteCapacity(satelliteData.length); positionUpdateAccumulator = POSITION_UPDATE_INTERVAL_MS; return { count: satelliteData.length, requestedLimit: limit, }; } export function updateSatellitePositions(deltaTime = 0, force = false, options = {}) { if (!satellitePoints || !satelliteBackdropPoints || satelliteData.length === 0) return; const shouldUpdateTrails = showSatellites || showTrails || lockedSatelliteIndex !== null; const shouldResetTrails = options.resetTrails || (!force && deltaTime >= BACKGROUND_TRAIL_RESET_DELTA_MS); if (shouldResetTrails) { clearSatelliteTrails(); positionUpdateAccumulator = POSITION_UPDATE_INTERVAL_MS; } else { positionUpdateAccumulator += deltaTime; } if (!force && positionUpdateAccumulator < POSITION_UPDATE_INTERVAL_MS) { return; } const elapsedMs = shouldResetTrails ? 0 : Math.max( positionUpdateAccumulator, POSITION_UPDATE_INTERVAL_MS, ); positionUpdateAccumulator = 0; const positions = satellitePoints.geometry.attributes.position.array; const backdropPositions = satelliteBackdropPoints.geometry.attributes.position.array; const colors = satellitePoints.geometry.attributes.color.array; const pointAlphas = satellitePoints.geometry.attributes.alpha.array; const backdropAlphas = satelliteBackdropPoints.geometry.attributes.alpha.array; const instanceStarts = satelliteTrails.geometry.attributes.instanceStart.array; const instanceEnds = satelliteTrails.geometry.attributes.instanceEnd.array; const instanceColorStarts = satelliteTrails.geometry.attributes.instanceColorStart.array; const instanceColorEnds = satelliteTrails.geometry.attributes.instanceColorEnd.array; const baseTime = new Date(Date.now() + elapsedMs); const count = Math.min(satelliteData.length, satelliteCapacity); let trailSegmentCount = 0; for (let i = 0; i < count; i++) { const satellite = satelliteData[i]; const props = satellite.properties; const timeOffset = (i / count) * 2 * Math.PI * 0.1; const adjustedTime = new Date( baseTime.getTime() + timeOffset * 1000 * 60 * 10, ); let pos = computeSatellitePosition(satellite, adjustedTime); if (!pos) { pos = generateFallbackPosition(satellite, i, count, adjustedTime); } satellitePositions[i].current.copy(pos); if (shouldUpdateTrails) { const satPos = satellitePositions[i]; if (satPos.trailCount === 0 && TRAIL_LENGTH > 1) { for (let k = 0; k < TRAIL_LENGTH; k++) { const offsetMs = (TRAIL_LENGTH - 1 - k) * POSITION_UPDATE_INTERVAL_MS; const pastTime = new Date(adjustedTime.getTime() - offsetMs); let pastPos = computeSatellitePosition(satellite, pastTime); if (!pastPos) { pastPos = generateFallbackPosition(satellite, i, count, pastTime); } satPos.trail[satPos.trailIndex] = pastPos; satPos.trailIndex = (satPos.trailIndex + 1) % TRAIL_LENGTH; } satPos.trailCount = TRAIL_LENGTH; } else { satPos.trail[satPos.trailIndex] = pos.clone(); satPos.trailIndex = (satPos.trailIndex + 1) % TRAIL_LENGTH; if (satPos.trailCount < TRAIL_LENGTH) satPos.trailCount++; } } positions[i * 3] = pos.x; positions[i * 3 + 1] = pos.y; positions[i * 3 + 2] = pos.z; backdropPositions[i * 3] = pos.x; backdropPositions[i * 3 + 1] = pos.y; backdropPositions[i * 3 + 2] = pos.z; const rule = getSatelliteLegendRule(props); const { r, g, b } = getSatelliteRuleColor(rule); const isNonFocusDimmed = highlightedSatelliteIndices !== null && !highlightedSatelliteIndices.has(i); const pointBrightness = isNonFocusDimmed ? DIMMED_SATELLITE_BRIGHTNESS : 1; const trailBrightness = isNonFocusDimmed ? DIMMED_SATELLITE_TRAIL_BRIGHTNESS : 1; colors[i * 3] = r * pointBrightness; colors[i * 3 + 1] = g * pointBrightness; colors[i * 3 + 2] = b * pointBrightness; const pointAlpha = shouldHideSatellitePoint(i) ? 0 : 1; pointAlphas[i] = pointAlpha; backdropAlphas[i] = pointAlpha; const satPosition = satellitePositions[i]; const tc = satPosition.trailCount; let hasVisibleTrail = false; for (let j = 0; j < TRAIL_LENGTH - 1; j++) { if (j + 1 < tc) { const idxA = (satPosition.trailIndex - tc + j + TRAIL_LENGTH) % TRAIL_LENGTH; const idxB = (satPosition.trailIndex - tc + j + 1 + TRAIL_LENGTH) % TRAIL_LENGTH; const ptA = satPosition.trail[idxA]; const ptB = satPosition.trail[idxB]; if (ptA && ptB && ptA.distanceToSquared(ptB) > 1e-8) { const base = trailSegmentCount * 3; instanceStarts[base] = ptA.x; instanceStarts[base + 1] = ptA.y; instanceStarts[base + 2] = ptA.z; instanceEnds[base] = ptB.x; instanceEnds[base + 1] = ptB.y; instanceEnds[base + 2] = ptB.z; const alphaA = (j + 1) / tc; const alphaB = (j + 2) / tc; instanceColorStarts[base] = r * alphaA * trailBrightness; instanceColorStarts[base + 1] = g * alphaA * trailBrightness; instanceColorStarts[base + 2] = b * alphaA * trailBrightness; instanceColorEnds[base] = r * alphaB * trailBrightness; instanceColorEnds[base + 1] = g * alphaB * trailBrightness; instanceColorEnds[base + 2] = b * alphaB * trailBrightness; hasVisibleTrail = true; trailSegmentCount++; } } } if (!hasVisibleTrail) { const base = trailSegmentCount * 3; const dist = Math.sqrt(pos.x * pos.x + pos.y * pos.y + pos.z * pos.z) || 1; const nx = pos.x / dist; const ny = pos.y / dist; const nz = pos.z / dist; const tip = FALLBACK_TRAIL_TIP_LENGTH; instanceStarts[base] = pos.x + nx * tip; instanceStarts[base + 1] = pos.y + ny * tip; instanceStarts[base + 2] = pos.z + nz * tip; instanceEnds[base] = pos.x; instanceEnds[base + 1] = pos.y; instanceEnds[base + 2] = pos.z; const fallbackAlphaStart = FALLBACK_TRAIL_ALPHA_START * trailBrightness; const fallbackAlphaEnd = FALLBACK_TRAIL_ALPHA_END * trailBrightness; instanceColorStarts[base] = r * fallbackAlphaStart; instanceColorStarts[base + 1] = g * fallbackAlphaStart; instanceColorStarts[base + 2] = b * fallbackAlphaStart; instanceColorEnds[base] = r * fallbackAlphaEnd; instanceColorEnds[base + 1] = g * fallbackAlphaEnd; instanceColorEnds[base + 2] = b * fallbackAlphaEnd; trailSegmentCount++; } } for (let i = count; i < satelliteCapacity; i++) { positions[i * 3] = 0; positions[i * 3 + 1] = 0; positions[i * 3 + 2] = 0; backdropPositions[i * 3] = 0; backdropPositions[i * 3 + 1] = 0; backdropPositions[i * 3 + 2] = 0; pointAlphas[i] = 0; backdropAlphas[i] = 0; } const trailArrayLength = instanceStarts.length / 3; for (let i = trailSegmentCount; i < trailArrayLength; i++) { const base = i * 3; instanceStarts[base] = 0; instanceStarts[base + 1] = 0; instanceStarts[base + 2] = 0; instanceEnds[base] = 0; instanceEnds[base + 1] = 0; instanceEnds[base + 2] = 0; instanceColorStarts[base] = 0; instanceColorStarts[base + 1] = 0; instanceColorStarts[base + 2] = 0; instanceColorEnds[base] = 0; instanceColorEnds[base + 1] = 0; instanceColorEnds[base + 2] = 0; } satellitePoints.geometry.attributes.position.needsUpdate = true; satellitePoints.geometry.attributes.color.needsUpdate = true; satellitePoints.geometry.attributes.alpha.needsUpdate = true; satellitePoints.geometry.setDrawRange(0, count); satelliteBackdropPoints.geometry.attributes.position.needsUpdate = true; satelliteBackdropPoints.geometry.attributes.alpha.needsUpdate = true; satelliteBackdropPoints.geometry.setDrawRange(0, count); for (const name of TRAIL_INSTANCE_ATTRIBUTE_NAMES) { satelliteTrails.geometry.attributes[name].needsUpdate = true; } satelliteTrails.geometry.instanceCount = trailSegmentCount; // Keep the hover ring synced with the propagated satellite position even // when the pointer stays still and no new hover event is emitted. if ( hoveredSatelliteIndex !== null && hoveredSatelliteIndex >= 0 && hoveredSatelliteIndex < count && hoveredSatelliteIndex !== lockedSatelliteIndex ) { updateHoverRingPosition(satellitePositions[hoveredSatelliteIndex].current); } } export function toggleSatellites(visible) { showSatellites = visible; if (satelliteBackdropPoints) { satelliteBackdropPoints.visible = visible; } if (satellitePoints) { satellitePoints.visible = visible; } if (satelliteTrails) { satelliteTrails.visible = visible && showTrails; } } export function toggleTrails(visible) { showTrails = visible; if (satelliteTrails) { satelliteTrails.visible = visible && showSatellites; } } export function getShowTrails() { return showTrails; } export function getShowSatellites() { return showSatellites; } export function getSatelliteCount() { return satelliteData.length; } export function getSatelliteAt(index) { if (index >= 0 && index < satelliteData.length) { return satelliteData[index]; } return null; } export function getSatelliteData() { return satelliteData; } export function selectSatellite(index) { selectedSatellite = index; return getSatelliteAt(index); } export function getSatellitePoints() { return satellitePoints; } export function getSatellitePositions() { return satellitePositions; } export function setSatelliteCamera(camera) { cameraRef = camera; } export function setSatelliteSunDirection(direction) { if (!direction) return; satelliteSunDirection.copy(direction).normalize(); if (lockedGroundFootprintFillMesh?.material?.uniforms?.uSunDirectionWorld) { lockedGroundFootprintFillMesh.material.uniforms.uSunDirectionWorld.value.copy( satelliteSunDirection, ); } } export function setLockedSatelliteIndex(index) { lockedSatelliteIndex = index; updateSatellitePointVisibilityAttributes(); } export function setHoveredSatelliteIndex(index) { hoveredSatelliteIndex = index; updateSatellitePointVisibilityAttributes(); } function normalizeSatelliteDisplayStyle(nextStyle) { return Object.values(SATELLITE_DISPLAY_STYLES).includes(nextStyle) ? nextStyle : DEFAULT_SATELLITE_DISPLAY_STYLE; } function normalizeSatelliteConstellationGroup(rawGroup) { const normalized = String(rawGroup || "") .trim() .toLowerCase(); return normalized || null; } function inferSatelliteConstellationGroup(props = {}) { const explicitGroup = normalizeSatelliteConstellationGroup( props.constellation_group, ); if (explicitGroup) { return explicitGroup; } const normalizedName = String(props.name || "") .trim() .toUpperCase(); if (normalizedName.startsWith("STARLINK")) { return "starlink"; } if (normalizedName.startsWith("IRIDIUM")) { return "iridium-next"; } return null; } function getSatelliteFootprintPolicy(props = {}) { const explicitPolicy = String(props.footprint_policy || "") .trim() .toLowerCase(); if (Object.values(SATELLITE_FOOTPRINT_POLICIES).includes(explicitPolicy)) { return explicitPolicy; } const constellationGroup = inferSatelliteConstellationGroup(props); if (constellationGroup === "starlink") { return SATELLITE_FOOTPRINT_POLICIES.STARLINK_GROUND_FOOTPRINT; } if (constellationGroup === "iridium-next") { return SATELLITE_FOOTPRINT_POLICIES.IRIDIUM_COVERAGE_RING; } return SATELLITE_FOOTPRINT_POLICIES.NONE; } function getSatelliteConstellationLabel(props = {}) { const constellationGroup = inferSatelliteConstellationGroup(props); if (!constellationGroup) return "未分类"; return ( SATELLITE_CONSTELLATION_LABELS[constellationGroup] || constellationGroup ); } function getSatellitePresentationMode(props = {}) { if (satelliteDisplayStyle !== SATELLITE_DISPLAY_STYLES.GROUND_FOOTPRINT) { return SATELLITE_PRESENTATION_MODES.SELF_GLOW; } const footprintPolicy = getSatelliteFootprintPolicy(props); if ( footprintPolicy === SATELLITE_FOOTPRINT_POLICIES.STARLINK_GROUND_FOOTPRINT ) { return SATELLITE_PRESENTATION_MODES.STARLINK_GROUND_FOOTPRINT; } if (footprintPolicy === SATELLITE_FOOTPRINT_POLICIES.IRIDIUM_COVERAGE_RING) { return SATELLITE_PRESENTATION_MODES.IRIDIUM_SPOT_BEAMS; } return SATELLITE_PRESENTATION_MODES.SELF_GLOW; } function getSatelliteFootprintCapabilityLabel(props = {}) { const footprintPolicy = getSatelliteFootprintPolicy(props); switch (footprintPolicy) { case SATELLITE_FOOTPRINT_POLICIES.STARLINK_GROUND_FOOTPRINT: return "支持 Starlink 地表覆盖"; case SATELLITE_FOOTPRINT_POLICIES.IRIDIUM_COVERAGE_RING: return "支持 Iridium 外圈覆盖"; default: return "默认不显示 footprint"; } } function getSatellitePresentationModeLabel(mode) { switch (mode) { case SATELLITE_PRESENTATION_MODES.STARLINK_GROUND_FOOTPRINT: return "真实地表覆盖(Starlink)"; case SATELLITE_PRESENTATION_MODES.IRIDIUM_SPOT_BEAMS: return "真实地表覆盖(Iridium 外圈)"; default: return "自身发光"; } } export function getSatellitePresentationInfo(props = {}) { const footprintPolicy = getSatelliteFootprintPolicy(props); const presentationMode = getSatellitePresentationMode(props); return { constellationGroup: inferSatelliteConstellationGroup(props), constellationLabel: getSatelliteConstellationLabel(props), footprintPolicy, footprintCapabilityLabel: getSatelliteFootprintCapabilityLabel(props), presentationMode, presentationModeLabel: getSatellitePresentationModeLabel( presentationMode, ), }; } function getLockedSatelliteProperties() { if (lockedSatelliteIndex === null) return null; return satelliteData[lockedSatelliteIndex]?.properties || null; } export function getSatelliteDisplayStyle() { return satelliteDisplayStyle; } export function setSatelliteDisplayStyle(nextStyle) { const normalizedStyle = normalizeSatelliteDisplayStyle(nextStyle); if (normalizedStyle === satelliteDisplayStyle) return satelliteDisplayStyle; satelliteDisplayStyle = normalizedStyle; if ( lockedSatelliteIndex !== null && satellitePositions?.[lockedSatelliteIndex]?.current ) { showHoverRing(satellitePositions[lockedSatelliteIndex].current, true); } else { clearLockedSatelliteStyleVisuals(); } return satelliteDisplayStyle; } export function isSatelliteFrontFacing(index, camera = cameraRef) { if (!earthObjRef || !camera) return true; if (!satellitePositions || !satellitePositions[index]) return true; const satPos = satellitePositions[index].current; if (!satPos) return true; scratchWorldSatellitePosition .copy(satPos) .applyMatrix4(earthObjRef.matrixWorld); scratchToCamera.subVectors(camera.position, earthObjRef.position).normalize(); scratchToSatellite .subVectors(scratchWorldSatellitePosition, earthObjRef.position) .normalize(); return ( scratchToCamera.dot(scratchToSatellite) > SATELLITE_CONFIG.frontFacingDotThreshold ); } function createLockedHaloMaterial(color = "#ffbf47") { return new THREE.ShaderMaterial({ transparent: true, depthTest: true, depthWrite: false, side: THREE.DoubleSide, uniforms: { uColor: { value: new THREE.Color(color) }, uOpacity: { value: LOCKED_HALO_BASE_OPACITY }, }, vertexShader: ` varying vec2 vUv; void main() { vUv = uv; gl_Position = projectionMatrix * modelViewMatrix * vec4(position, 1.0); } `, fragmentShader: ` uniform vec3 uColor; uniform float uOpacity; varying vec2 vUv; void main() { vec2 centeredUv = vUv - vec2(0.5); float distanceToCenter = length(centeredUv) * 2.0; float outerFade = 1.0 - smoothstep(0.34, 1.0, distanceToCenter); float innerFade = smoothstep(0.18, 0.48, distanceToCenter); float alpha = outerFade * innerFade * uOpacity; if (alpha <= 0.001) discard; gl_FragColor = vec4(uColor, alpha); } `, }); } function createGroundFootprintMaterial() { return new THREE.ShaderMaterial({ transparent: true, side: THREE.DoubleSide, depthTest: true, depthWrite: false, uniforms: { uColor: { value: new THREE.Color(0xffffff) }, uOpacity: { value: 0.46 }, uMajorKm: { value: 1000 }, uMinorKm: { value: 700 }, uGapCenterNorthKm: { value: 0 }, uGapLengthKm: { value: 1000 }, uGapWidthCenterKm: { value: GROUND_FOOTPRINT_GAP_WIDTH_CENTER_KM }, uGapWidthEdgeKm: { value: GROUND_FOOTPRINT_GAP_WIDTH_EDGE_KM }, uEastAlongDot: { value: 1 }, uEastCrossDot: { value: 0 }, uNorthAlongDot: { value: 0 }, uNorthCrossDot: { value: 1 }, uSoftOuterStart: { value: 0.0 }, uSoftOuterEnd: { value: 1.0 }, uGapSoftnessKm: { value: 6 }, uGlowMode: { value: 0 }, uSunDirectionWorld: { value: satelliteSunDirection.clone() }, uDayVisibilityBoost: { value: 1.48 }, }, vertexShader: ` varying vec3 vWorldPosition; varying vec2 vUv; void main() { vUv = uv; vec4 worldPosition = modelMatrix * vec4(position, 1.0); vWorldPosition = worldPosition.xyz; gl_Position = projectionMatrix * modelViewMatrix * vec4(position, 1.0); } `, fragmentShader: ` uniform vec3 uColor; uniform float uOpacity; uniform float uMajorKm; uniform float uMinorKm; uniform float uGapCenterNorthKm; uniform float uGapLengthKm; uniform float uGapWidthCenterKm; uniform float uGapWidthEdgeKm; uniform float uEastAlongDot; uniform float uEastCrossDot; uniform float uNorthAlongDot; uniform float uNorthCrossDot; uniform float uSoftOuterStart; uniform float uSoftOuterEnd; uniform float uGapSoftnessKm; uniform float uGlowMode; uniform vec3 uSunDirectionWorld; uniform float uDayVisibilityBoost; varying vec3 vWorldPosition; varying vec2 vUv; float bowtieHalfWidth(float xEast) { float t = clamp(abs(xEast) / max(uGapLengthKm, 1.0), 0.0, 1.0); return mix(uGapWidthCenterKm, uGapWidthEdgeKm, pow(t, 1.7)); } void main() { vec2 p = vUv * 2.0 - 1.0; float ellipseMetric = dot(p, p); float centerGlow = exp(-ellipseMetric * 0.5); float edgeFade = 1.0 - smoothstep(0.28, 1.0, ellipseMetric); float outerAlpha = centerGlow * pow(max(edgeFade, 0.0), 1.45); if (outerAlpha <= 0.001) discard; float alongKm = p.x * uMajorKm; float crossKm = p.y * uMinorKm; float xEast = alongKm * uEastAlongDot + crossKm * uEastCrossDot; float yNorth = alongKm * uNorthAlongDot + crossKm * uNorthCrossDot; float gapMask = 1.0; if (abs(xEast) <= uGapLengthKm) { float gapHalfWidth = bowtieHalfWidth(xEast); float distToGap = abs(yNorth - uGapCenterNorthKm) - gapHalfWidth; gapMask = smoothstep(-uGapSoftnessKm, uGapSoftnessKm, distToGap); } float alpha = outerAlpha * gapMask * uOpacity; if (uGlowMode > 0.5) { alpha *= 0.92; } else { alpha *= 1.34; } vec3 worldNormal = normalize(vWorldPosition); vec3 toCam = normalize(cameraPosition - vWorldPosition); float facing = dot(worldNormal, toCam); float limbFade = smoothstep(-0.08, 0.12, facing); if (limbFade <= 0.0) discard; alpha *= limbFade; vec3 sunDir = normalize(uSunDirectionWorld); float sunFacing = dot(worldNormal, sunDir); float daylight = clamp(sunFacing * 0.5 + 0.5, 0.0, 1.0); alpha *= mix(1.0, uDayVisibilityBoost, daylight); vec3 nightColor = vec3(0.24, 0.56, 1.0); vec3 dayColor = vec3(1.0, 0.72, 0.08); vec3 finalColor = mix(nightColor, dayColor, daylight); if (alpha <= 0.001) discard; gl_FragColor = vec4(finalColor, alpha); } `, }); } function smoothstep(edge0, edge1, x) { const t = THREE.MathUtils.clamp((x - edge0) / (edge1 - edge0), 0, 1); return t * t * (3 - 2 * t); } function centralAngleForMinElevation(heightKm, elevationDeg) { const elevationRad = THREE.MathUtils.degToRad(elevationDeg); const orbitalRadiusKm = EARTH_RADIUS_KM + heightKm; let low = 0; let high = Math.acos(EARTH_RADIUS_KM / orbitalRadiusKm) - 1e-5; function elevationAt(gamma) { const ground = new THREE.Vector3( EARTH_RADIUS_KM * Math.cos(gamma), EARTH_RADIUS_KM * Math.sin(gamma), 0, ); const satellite = new THREE.Vector3(orbitalRadiusKm, 0, 0); const surfaceNormal = ground.clone().normalize(); const toSatellite = satellite.clone().sub(ground).normalize(); return Math.asin( THREE.MathUtils.clamp(surfaceNormal.dot(toSatellite), -1, 1), ); } for (let iteration = 0; iteration < 48; iteration += 1) { const mid = (low + high) * 0.5; if (elevationAt(mid) > elevationRad) { low = mid; } else { high = mid; } } return low; } function clearLockedSatelliteStyleVisuals() { if (lockedDotSprite) { disposeObject3D(lockedDotSprite, sceneRef); lockedDotSprite = null; } if (lockedHaloMesh) { disposeObject3D(lockedHaloMesh, sceneRef); lockedHaloMesh = null; } if (lockedGroundFootprintMesh) { disposeObjectTree(lockedGroundFootprintMesh); lockedGroundFootprintMesh = null; lockedGroundFootprintFillMesh = null; hasGroundFootprintGeometry = false; } if (lockedIridiumFootprintMesh) { disposeIridiumFootprintAdapter(lockedIridiumFootprintMesh, earthObjRef); lockedIridiumFootprintMesh = null; } } function updateLockedDotWorldTransform(position) { if (!lockedDotSprite || !position || !earthObjRef) return; earthObjRef.updateMatrixWorld(true); const worldPosition = position.clone().applyMatrix4(earthObjRef.matrixWorld); lockedDotSprite.position.copy(worldPosition); if (cameraRef) { lockedDotSprite.quaternion.copy(cameraRef.quaternion); } const viewportHeight = window.innerHeight || 1080; const distanceToCamera = cameraRef ? Math.max(cameraRef.position.distanceTo(worldPosition), 1) : CONFIG.defaultCameraZ; const verticalFovRad = cameraRef?.isPerspectiveCamera ? THREE.MathUtils.degToRad(cameraRef.fov) : THREE.MathUtils.degToRad(45); const worldUnitsPerPixel = (2 * Math.tan(verticalFovRad / 2) * distanceToCamera) / viewportHeight; const coreRadiusWorld = worldUnitsPerPixel * LOCKED_HALO_CORE_PIXEL_RADIUS; lockedDotSprite.scale.set(coreRadiusWorld, coreRadiusWorld, 1); } function updateLockedHaloWorldTransform(position) { if (!position || !earthObjRef || !lockedHaloMesh) return; earthObjRef.updateMatrixWorld(true); const worldPosition = position.clone().applyMatrix4(earthObjRef.matrixWorld); const viewDirection = cameraRef ? scratchToCamera.subVectors(cameraRef.position, worldPosition).normalize() : null; lockedHaloMesh.position.copy(worldPosition); if (viewDirection) { lockedHaloMesh.position.addScaledVector(viewDirection, -LOCKED_HALO_OFFSET); } if (cameraRef) { lockedHaloMesh.quaternion.copy(cameraRef.quaternion); } const viewportHeight = window.innerHeight || 1080; const distanceToCamera = cameraRef ? Math.max(cameraRef.position.distanceTo(worldPosition), 1) : CONFIG.defaultCameraZ; const verticalFovRad = cameraRef?.isPerspectiveCamera ? THREE.MathUtils.degToRad(cameraRef.fov) : THREE.MathUtils.degToRad(45); const worldUnitsPerPixel = (2 * Math.tan(verticalFovRad / 2) * distanceToCamera) / viewportHeight; const haloRadiusWorld = worldUnitsPerPixel * LOCKED_HALO_PIXEL_RADIUS; lockedHaloMesh.scale.set(haloRadiusWorld, haloRadiusWorld, 1); } function estimateLockedSatelliteAltitudeKm() { if (lockedSatelliteIndex === null) return GROUND_FOOTPRINT_DEFAULT_ALTITUDE_KM; const satellite = satelliteData[lockedSatelliteIndex]; const props = satellite?.properties; if (!props?.norad_cat_id) return GROUND_FOOTPRINT_DEFAULT_ALTITUDE_KM; const satrec = getOrBuildSatrec(props, new Date()); if (!satrec || satrec.error) return GROUND_FOOTPRINT_DEFAULT_ALTITUDE_KM; const propagation = propagate(satrec, new Date()); const rawPosition = propagation?.position; if (!rawPosition) return GROUND_FOOTPRINT_DEFAULT_ALTITUDE_KM; const radiusKm = Math.sqrt( rawPosition.x * rawPosition.x + rawPosition.y * rawPosition.y + rawPosition.z * rawPosition.z, ); if (!Number.isFinite(radiusKm)) return GROUND_FOOTPRINT_DEFAULT_ALTITUDE_KM; return Math.max(0, radiusKm - EARTH_RADIUS_KM); } function estimateGroundCoverageAngleRad(altitudeKm) { return Math.max( 0.03, centralAngleForMinElevation( Math.max(altitudeKm, 10), GROUND_FOOTPRINT_MIN_ELEVATION_DEG, ), ); } function getLockedSatelliteTrackDirection(groundNormal) { if (lockedSatelliteIndex === null) return null; const satellite = satelliteData[lockedSatelliteIndex]; const props = satellite?.properties; if (!props?.norad_cat_id) return null; const satrec = getOrBuildSatrec(props, new Date()); if (!satrec || satrec.error) return null; const propagation = propagate(satrec, new Date()); const velocity = propagation?.velocity; if (!velocity) return null; scratchFootprintVelocity.set(velocity.x, velocity.y, velocity.z); if (!Number.isFinite(scratchFootprintVelocity.lengthSq())) return null; scratchFootprintTangent .copy(scratchFootprintVelocity) .projectOnPlane(groundNormal); if (scratchFootprintTangent.lengthSq() <= 1e-6) { return null; } return scratchFootprintTangent.normalize().clone(); } function buildSurfaceFrame(position) { const centerNormal = position.clone().normalize(); const alongTrack = getLockedSatelliteTrackDirection(centerNormal) || scratchFootprintTrack.set(0, 1, 0).projectOnPlane(centerNormal).normalize(); if (alongTrack.lengthSq() <= 1e-6) { alongTrack.copy(scratchFootprintReference.set(1, 0, 0)); } const crossTrack = scratchFootprintLateral .crossVectors(centerNormal, alongTrack) .normalize() .clone(); return { centerNormal, alongTrack: alongTrack.clone(), crossTrack, }; } function projectFootprintOffsetToSurface( centerNormal, alongTrack, crossTrack, alongKm, crossKm, surfaceRadius, ) { const worldUnitsPerKm = CONFIG.earthRadius / EARTH_RADIUS_KM; return centerNormal .clone() .multiplyScalar(CONFIG.earthRadius) .addScaledVector(alongTrack, alongKm * worldUnitsPerKm) .addScaledVector(crossTrack, crossKm * worldUnitsPerKm) .normalize() .multiplyScalar(surfaceRadius); } function buildGroundFootprintGeometry(position) { const altitudeKm = estimateLockedSatelliteAltitudeKm(); const coverageRadiusKm = EARTH_RADIUS_KM * estimateGroundCoverageAngleRad(altitudeKm) * GROUND_FOOTPRINT_SERVICE_RADIUS_FACTOR; const surfaceRadius = CONFIG.earthRadius * GROUND_FOOTPRINT_SURFACE_SCALE + GROUND_FOOTPRINT_RADIUS_OFFSET; const { centerNormal, alongTrack, crossTrack } = buildSurfaceFrame(position); const absLatitudeDeg = Math.abs( THREE.MathUtils.radToDeg(Math.asin(centerNormal.y)), ); const latitudeBlend = THREE.MathUtils.clamp( absLatitudeDeg / GROUND_FOOTPRINT_LATITUDE_BLEND_DEG, 0, 1, ); const axisRatio = THREE.MathUtils.lerp( GROUND_FOOTPRINT_LOW_LAT_AXIS_RATIO, GROUND_FOOTPRINT_HIGH_LAT_AXIS_RATIO, latitudeBlend, ); const majorKm = coverageRadiusKm * axisRatio; const minorKm = coverageRadiusKm / axisRatio; const worldNorth = new THREE.Vector3(0, 1, 0); let east = scratchFootprintReference .crossVectors(worldNorth, centerNormal) .normalize() .clone(); if (east.lengthSq() < 1e-6) { east = alongTrack.clone(); } const north = new THREE.Vector3().crossVectors(centerNormal, east).normalize(); const latitudeSign = centerNormal.y >= 0 ? 1 : -1; const gapCenterNorthKm = latitudeSign * THREE.MathUtils.lerp( GROUND_FOOTPRINT_GAP_CENTER_MIN_RATIO * minorKm, GROUND_FOOTPRINT_GAP_CENTER_MAX_RATIO * minorKm, smoothstep(0.06, 0.95, latitudeBlend), ); const exclusionLengthKm = GROUND_FOOTPRINT_GAP_LENGTH_RATIO * majorKm; function isInsideEllipse(alongKm, crossKm) { return ( (alongKm * alongKm) / (majorKm * majorKm) + (crossKm * crossKm) / (minorKm * minorKm) <= 1 ); } function bowtieHalfWidth(xEast) { const t = THREE.MathUtils.clamp( Math.abs(xEast) / Math.max(exclusionLengthKm, 1), 0, 1, ); return THREE.MathUtils.lerp( GROUND_FOOTPRINT_GAP_WIDTH_CENTER_KM, GROUND_FOOTPRINT_GAP_WIDTH_EDGE_KM, Math.pow(t, 1.7), ); } const vertices = []; const uvs = []; const indices = []; const indexMap = []; for (let iy = 0; iy <= GROUND_FOOTPRINT_GRID_Y; iy += 1) { const row = []; const crossKm = THREE.MathUtils.lerp( -minorKm, minorKm, iy / GROUND_FOOTPRINT_GRID_Y, ); for (let ix = 0; ix <= GROUND_FOOTPRINT_GRID_X; ix += 1) { const alongKm = THREE.MathUtils.lerp( -majorKm, majorKm, ix / GROUND_FOOTPRINT_GRID_X, ); if (!isInsideEllipse(alongKm, crossKm)) { row.push(-1); continue; } const point = projectFootprintOffsetToSurface( centerNormal, alongTrack, crossTrack, alongKm, crossKm, surfaceRadius, ); row.push(vertices.length / 3); vertices.push(point.x, point.y, point.z); uvs.push( THREE.MathUtils.mapLinear(alongKm, -majorKm, majorKm, 0, 1), THREE.MathUtils.mapLinear(crossKm, -minorKm, minorKm, 0, 1), ); } indexMap.push(row); } for (let iy = 0; iy < GROUND_FOOTPRINT_GRID_Y; iy += 1) { for (let ix = 0; ix < GROUND_FOOTPRINT_GRID_X; ix += 1) { const a = indexMap[iy][ix]; const b = indexMap[iy][ix + 1]; const c = indexMap[iy + 1][ix]; const d = indexMap[iy + 1][ix + 1]; if (a < 0 || b < 0 || c < 0 || d < 0) continue; indices.push(a, c, b); indices.push(b, c, d); } } const fillGeometry = new THREE.BufferGeometry(); fillGeometry.setAttribute( "position", new THREE.Float32BufferAttribute(vertices, 3), ); fillGeometry.setAttribute("uv", new THREE.Float32BufferAttribute(uvs, 2)); fillGeometry.setIndex(indices); const basisToEastNorth = { eastAlongDot: alongTrack.dot(east), eastCrossDot: crossTrack.dot(east), northAlongDot: alongTrack.dot(north), northCrossDot: crossTrack.dot(north), }; return { fillGeometry, majorKm, minorKm, gapCenterNorthKm, exclusionLengthKm, basisToEastNorth, }; } function updateGroundFootprintTransform(position) { if (!lockedGroundFootprintMesh || !position || !earthObjRef) return; if ( hasGroundFootprintGeometry && scratchLastGroundFootprintPosition.distanceToSquared(position) <= GROUND_FOOTPRINT_REBUILD_DISTANCE_SQ ) { return; } const geometrySet = buildGroundFootprintGeometry(position); if (!geometrySet) return; const fillMesh = lockedGroundFootprintFillMesh; if (fillMesh?.geometry) fillMesh.geometry.dispose(); if (fillMesh) { fillMesh.geometry = geometrySet.fillGeometry; if (fillMesh.material?.uniforms) { fillMesh.material.uniforms.uMajorKm.value = geometrySet.majorKm; fillMesh.material.uniforms.uMinorKm.value = geometrySet.minorKm; fillMesh.material.uniforms.uGapCenterNorthKm.value = geometrySet.gapCenterNorthKm; fillMesh.material.uniforms.uGapLengthKm.value = geometrySet.exclusionLengthKm; fillMesh.material.uniforms.uEastAlongDot.value = geometrySet.basisToEastNorth.eastAlongDot; fillMesh.material.uniforms.uEastCrossDot.value = geometrySet.basisToEastNorth.eastCrossDot; fillMesh.material.uniforms.uNorthAlongDot.value = geometrySet.basisToEastNorth.northAlongDot; fillMesh.material.uniforms.uNorthCrossDot.value = geometrySet.basisToEastNorth.northCrossDot; } scratchLastGroundFootprintPosition.copy(position); hasGroundFootprintGeometry = true; } } function showSelfGlowStyle(position, color = "#ffd25a") { const dotGeometry = new THREE.CircleGeometry( LOCKED_HALO_CORE_RADIUS, LOCKED_HALO_CORE_SEGMENTS, ); const dotMaterial = new THREE.MeshBasicMaterial({ color: new THREE.Color(color), transparent: true, opacity: 0.96, depthTest: true, depthWrite: false, side: THREE.DoubleSide, }); lockedDotSprite = new THREE.Mesh(dotGeometry, dotMaterial); lockedDotSprite.renderOrder = SATELLITE_CONFIG.overlayRenderOrder + 2; updateLockedDotWorldTransform(position); sceneRef?.add(lockedDotSprite); lockedHaloMesh = new THREE.Mesh( new THREE.CircleGeometry(LOCKED_HALO_RADIUS, 64), createLockedHaloMaterial(color), ); lockedHaloMesh.renderOrder = SATELLITE_CONFIG.overlayRenderOrder + 1; sceneRef?.add(lockedHaloMesh); updateLockedHaloWorldTransform(position); } function showGroundFootprintStyle(position) { if (!earthObjRef) return; lockedGroundFootprintMesh = new THREE.Group(); lockedGroundFootprintMesh.renderOrder = 0; const fill = new THREE.Mesh( new THREE.BufferGeometry(), createGroundFootprintMaterial(), ); fill.name = "footprint-fill"; fill.renderOrder = GROUND_FOOTPRINT_RENDER_ORDER; lockedGroundFootprintMesh.add(fill); lockedGroundFootprintFillMesh = fill; hasGroundFootprintGeometry = false; earthObjRef.add(lockedGroundFootprintMesh); updateGroundFootprintTransform(position); } function showIridiumReservedStyle(position) { if (!earthObjRef || !position) return; lockedIridiumFootprintMesh = createIridiumFootprintAdapter({ earthObj: earthObjRef, earthRadiusWorld: CONFIG.earthRadius, renderOrder: GROUND_FOOTPRINT_RENDER_ORDER, }); updateIridiumReservedStyle(position); } function updateIridiumReservedStyle(position) { if (!lockedIridiumFootprintMesh || !position) return; const { alongTrack, crossTrack } = buildSurfaceFrame(position); updateIridiumFootprintAdapter(lockedIridiumFootprintMesh, { position, alongTrack, crossTrack, altitudeKm: estimateLockedSatelliteAltitudeKm(), }); } function createRingSprite(position, isLocked = false, color = "#ffcc00") { if (!earthObjRef) return null; const ringTexture = createRingTexture( 8, 12, isLocked ? color : "#ffffff", isLocked ? LOCKED_RING_HOVER_LINE_WIDTH : HOVER_RING_LINE_WIDTH, ); const filledTexture = isLocked ? createFilledCircleTexture(color) : null; const spriteMaterial = new THREE.SpriteMaterial({ map: ringTexture, transparent: true, opacity: 0.8, depthTest: true, depthWrite: false, alphaTest: 0.01, sizeAttenuation: false, }); const sprite = new THREE.Sprite(spriteMaterial); sprite.userData.ringTexture = ringTexture; sprite.userData.filledTexture = filledTexture; sprite.position.copy(position); sprite.scale.set(SATELLITE_CONFIG.ringSize, SATELLITE_CONFIG.ringSize, 1); sprite.renderOrder = SATELLITE_CONFIG.overlayRenderOrder; earthObjRef.add(sprite); return sprite; } function isLockedSatelliteHovered() { return ( lockedSatelliteIndex !== null && hoveredSatelliteIndex !== null && hoveredSatelliteIndex === lockedSatelliteIndex ); } function updateLockedMarkerVisual(isHovered) { if (!lockedRingSprite?.material) return; const nextTexture = isHovered ? lockedRingSprite.userData.ringTexture : lockedRingSprite.userData.filledTexture || lockedRingSprite.userData.ringTexture; if (lockedRingSprite.material.map !== nextTexture) { lockedRingSprite.material.map = nextTexture; lockedRingSprite.material.needsUpdate = true; } } function createRelatedSatelliteSprite(position) { return createRingSprite(position, false); } export function showHoverRing(position, isLocked = false) { if (!earthObjRef || !position) return null; if (isLocked) { const lockedProps = getLockedSatelliteProperties() || {}; const legendColor = getSatelliteLegendRule(lockedProps).color; hideLockedRing(); lockedRingSprite = createRingSprite(position, true, legendColor); updateLockedMarkerVisual(isLockedSatelliteHovered()); const presentationMode = getSatellitePresentationMode(lockedProps); if ( presentationMode === SATELLITE_PRESENTATION_MODES.STARLINK_GROUND_FOOTPRINT ) { showGroundFootprintStyle(position); } else if ( presentationMode === SATELLITE_PRESENTATION_MODES.IRIDIUM_SPOT_BEAMS ) { showIridiumReservedStyle(position); } else { showSelfGlowStyle(position, legendColor); } return lockedRingSprite; } hideHoverRings(); hoverRingSprite = createRingSprite(position, false); return hoverRingSprite; } export function hideHoverRings() { if (hoverRingSprite) { disposeObject3D(hoverRingSprite); hoverRingSprite = null; } } export function hideLockedRing() { if (lockedRingSprite) { const ringTexture = lockedRingSprite.userData?.ringTexture; const filledTexture = lockedRingSprite.userData?.filledTexture; disposeObject3D(lockedRingSprite); if (ringTexture && ringTexture !== lockedRingSprite.material?.map) { ringTexture.dispose(); } if (filledTexture && filledTexture !== lockedRingSprite.material?.map) { filledTexture.dispose(); } lockedRingSprite = null; } clearLockedSatelliteStyleVisuals(); } export function updateLockedRingPosition(position) { if (!position) return; const presentationMode = getSatellitePresentationMode( getLockedSatelliteProperties() || {}, ); const hasStyleVisual = presentationMode === SATELLITE_PRESENTATION_MODES.STARLINK_GROUND_FOOTPRINT ? Boolean(lockedGroundFootprintMesh) : presentationMode === SATELLITE_PRESENTATION_MODES.IRIDIUM_SPOT_BEAMS ? Boolean(lockedIridiumFootprintMesh) : Boolean(lockedDotSprite && lockedHaloMesh); if (!lockedRingSprite || !hasStyleVisual) { showHoverRing(position, true); } if (lockedRingSprite) { const isHovered = isLockedSatelliteHovered(); updateLockedMarkerVisual(isHovered); lockedRingSprite.position.copy(position); const ringPulse = getBreathingPulse(breathingPhase); const breathScale = 1 + (ringPulse * 2 - 1) * SATELLITE_CONFIG.breathingScaleAmplitude; const markerSize = isHovered ? SATELLITE_CONFIG.ringSize * LOCKED_RING_HOVER_SCALE : SATELLITE_CONFIG.ringSize * LOCKED_RING_IDLE_SCALE; lockedRingSprite.scale.set( markerSize * breathScale, markerSize * breathScale, 1, ); lockedRingSprite.material.opacity = isHovered ? SATELLITE_CONFIG.breathingOpacityMin + ringPulse * (SATELLITE_CONFIG.breathingOpacityMax - SATELLITE_CONFIG.breathingOpacityMin) : LOCKED_RING_IDLE_OPACITY; } if (lockedDotSprite) { updateLockedDotWorldTransform(position); const dotPulse = getBreathingPulse(breathingPhase); const dotBreathScale = 1 + (dotPulse * 2 - 1) * SATELLITE_CONFIG.dotBreathingScaleAmplitude; lockedDotSprite.scale.multiplyScalar(dotBreathScale); lockedDotSprite.material.opacity = SATELLITE_CONFIG.dotOpacityMin + dotPulse * (SATELLITE_CONFIG.dotOpacityMax - SATELLITE_CONFIG.dotOpacityMin); } if (lockedHaloMesh) { updateLockedHaloWorldTransform(position); const haloPulse = getBreathingPulse(breathingPhase); const pulseScale = 1 + (haloPulse * 2 - 1) * SATELLITE_CONFIG.dotBreathingScaleAmplitude * 0.32; lockedHaloMesh.scale.multiplyScalar(pulseScale); lockedHaloMesh.material.uniforms.uOpacity.value = LOCKED_HALO_BASE_OPACITY * (0.9 + haloPulse * 0.16); } if (lockedGroundFootprintMesh) { updateGroundFootprintTransform(position); } if (lockedIridiumFootprintMesh) { updateIridiumReservedStyle(position); } } export function updateHoverRingPosition(position) { if (hoverRingSprite && position) { hoverRingSprite.position.copy(position); hoverRingSprite.scale.set( SATELLITE_CONFIG.ringSize, SATELLITE_CONFIG.ringSize, 1, ); } } export function setSatelliteRingState(index, state, position) { switch (state) { case "hover": hoveredSatelliteIndex = index; hideHoverRings(); showHoverRing(position, false); updateSatellitePointVisibilityAttributes(); break; case "locked": hideHoverRings(); showHoverRing(position, true); updateSatellitePointVisibilityAttributes(); break; case "none": hoveredSatelliteIndex = null; hideHoverRings(); hideLockedRing(); updateSatellitePointVisibilityAttributes(); break; } } function applyDimMaterialState(isDimmed) { if (satellitePoints) { const opacity = isDimmed ? DIMMED_SATELLITE_POINT_OPACITY : 0.9; if (satellitePoints.material.uniforms?.opacity) { satellitePoints.material.uniforms.opacity.value = opacity; } else { satellitePoints.material.opacity = opacity; } } if (satelliteBackdropPoints) { const opacity = isDimmed ? DIMMED_SATELLITE_BACKDROP_OPACITY : 0.42; if (satelliteBackdropPoints.material.uniforms?.opacity) { satelliteBackdropPoints.material.uniforms.opacity.value = opacity; } else { satelliteBackdropPoints.material.opacity = opacity; } } } export function clearRelatedSatelliteHighlights() { relatedSatelliteSprites.forEach((item) => { if (item.sprite) { disposeObject3D(item.sprite); } }); relatedSatelliteSprites = []; highlightedSatelliteIndices = null; applyDimMaterialState(false); } export function highlightRelatedSatellites(indices, color = "#7dd3fc") { clearRelatedSatelliteHighlights(); if (!Array.isArray(indices) || indices.length === 0) return; highlightedSatelliteIndices = new Set(indices); applyDimMaterialState(true); indices.forEach((index) => { const pos = satellitePositions?.[index]?.current; if (!pos) return; const sprite = createRelatedSatelliteSprite(pos); if (!sprite) return; relatedSatelliteSprites.push({ index, sprite, color }); }); } export function updateRelatedSatelliteHighlights() { if (relatedSatelliteSprites.length === 0) return; relatedSatelliteSprites = relatedSatelliteSprites.filter((item) => { const pos = satellitePositions?.[item.index]?.current; if (!pos || !item.sprite) return false; item.sprite.position.copy(pos); return true; }); } export function getRelatedSatelliteIndicesForRegions( regions, { limit = 6, maxAngleDeg = 22 } = {}, ) { if (!Array.isArray(regions) || regions.length === 0 || satellitePositions.length === 0) { return []; } const regionVectors = regions .filter( (region) => typeof region?.latitude === "number" && typeof region?.longitude === "number", ) .map((region) => latLonToVector3(region.latitude, region.longitude, CONFIG.earthRadius + 1) .clone() .normalize(), ); if (regionVectors.length === 0) return []; const threshold = Math.cos((maxAngleDeg * Math.PI) / 180); const ranked = []; satellitePositions.forEach((item, index) => { const current = item?.current; if (!current || current.lengthSq() === 0) return; const satVector = current.clone().normalize(); let bestDot = -1; regionVectors.forEach((regionVector) => { bestDot = Math.max(bestDot, satVector.dot(regionVector)); }); if (bestDot >= threshold) { ranked.push({ index, score: bestDot }); } }); return ranked .sort((a, b) => b.score - a.score) .slice(0, limit) .map((item) => item.index); } export function initSatelliteScene(scene, earth) { sceneRef = scene; earthObjRef = earth; } function calculateOrbitalPeriod(meanMotion) { return 86400 / meanMotion; } function calculatePredictedOrbit( satellite, periodSeconds, sampleInterval = 10, ) { const points = []; const samples = Math.ceil(periodSeconds / sampleInterval); const now = new Date(); 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); }); 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; }