diff --git a/.sisyphus/plans/predicted-orbit.md b/.sisyphus/plans/predicted-orbit.md new file mode 100644 index 00000000..bc407693 --- /dev/null +++ b/.sisyphus/plans/predicted-orbit.md @@ -0,0 +1,136 @@ +# 卫星预测轨道显示功能 + +## TL;DR +> 锁定卫星时显示绕地球完整一圈的预测轨道轨迹,从当前位置向外渐变消失 + +## Context + +### 目标 +点击锁定卫星 → 显示该卫星绕地球一周的完整预测轨道(而非当前的历史轨迹) + +### 当前实现 +- `TRAIL_LENGTH = 30` - 历史轨迹点数,每帧 push 当前位置 +- 显示最近30帧历史轨迹(类似彗星尾巴) + +### 参考: SatelliteMap.space +- 锁定时显示预测轨道 +- 颜色从当前位置向外渐变消失 +- 使用 satellite.js(与本项目相同) + +## 实现状态 + +### ✅ 已完成 +- [x] 计算卫星轨道周期(基于 `meanMotion`) +- [x] 生成预测轨道点(10秒采样间隔) +- [x] 创建独立预测轨道渲染对象 +- [x] 锁定卫星时显示预测轨道 +- [x] 解除锁定时隐藏预测轨道 +- [x] 颜色渐变:当前位置(亮) → 轨道终点(暗) +- [x] 页面隐藏时清除轨迹(防止切回时闪现) + +### 🚧 进行中 +- [ ] 完整圆环轨道(部分卫星因 SGP4 计算问题使用 fallback 圆形轨道) +- [ ] 每颗卫星只显示一条轨道 + +## 技术细节 + +### 轨道周期计算 +```javascript +function calculateOrbitalPeriod(meanMotion) { + return 86400 / meanMotion; +} +``` + +### 预测轨道计算 +```javascript +function calculatePredictedOrbit(satellite, periodSeconds, sampleInterval = 10) { + const points = []; + const samples = Math.ceil(periodSeconds / sampleInterval); + const now = new Date(); + + // Full orbit: from now to now+period + 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); + } + + // Fallback: 如果真实位置计算点太少,使用圆形 fallback + if (points.length < samples * 0.5) { + points.length = 0; + // ... 圆形轨道生成 + } + + return points; +} +``` + +### 渲染对象 +```javascript +let predictedOrbitLine = null; + +export function showPredictedOrbit(satellite) { + hidePredictedOrbit(); + // ... 计算并渲染轨道 +} + +export function hidePredictedOrbit() { + if (predictedOrbitLine) { + earthObjRef.remove(predictedOrbitLine); + predictedOrbitLine.geometry.dispose(); + predictedOrbitLine.material.dispose(); + predictedOrbitLine = null; + } +} +``` + +## 已知问题 + +### 1. TLE 格式问题 +`computeSatellitePosition` 使用自行构建的 TLE 格式,对某些卫星返回 null。当前使用 fallback 圆形轨道作为补偿。 + +### 2. 多条轨道 +部分情况下锁定时会显示多条轨道。需要确保 `hidePredictedOrbit()` 被正确调用。 + +## 性能考虑 + +### 点数估算 +| 卫星类型 | 周期 | 10秒采样 | 点数 | +|---------|------|---------|------| +| LEO | 90分钟 | 540秒 | ~54点 | +| MEO | 12小时 | 4320秒 | ~432点 | +| GEO | 24小时 | 8640秒 | ~864点 | + +### 优化策略 +- 当前方案(~900点 GEO)性能可接受 +- 如遇性能问题:GEO 降低采样率到 30秒 + +## 验证方案 + +### QA Scenarios + +**Scenario: 锁定 Starlink 卫星显示预测轨道** +1. 打开浏览器,进入 Earth 页面 +2. 显示卫星(点击按钮) +3. 点击一颗 Starlink 卫星(低轨道 LEO) +4. 验证:出现黄色预测轨道线,从卫星向外绕行 +5. 验证:颜色从亮黄渐变到暗蓝 +6. 验证:轨道完整闭环 + +**Scenario: 锁定 GEO 卫星显示预测轨道** +1. 筛选一颗 GEO 卫星(倾斜角 0-10° 或高轨道) +2. 点击锁定 +3. 验证:显示完整 24 小时轨道(或 fallback 圆形轨道) +4. 验证:点数合理(~864点或 fallback) + +**Scenario: 解除锁定隐藏预测轨道** +1. 锁定一颗卫星,显示预测轨道 +2. 点击地球空白处解除锁定 +3. 验证:预测轨道消失 + +**Scenario: 切换页面后轨迹不闪现** +1. 锁定一颗卫星 +2. 切换到其他标签页 +3. 等待几秒 +4. 切回页面 +5. 验证:轨迹不突然闪现累积 diff --git a/frontend/public/earth/js/constants.js b/frontend/public/earth/js/constants.js index d6e846a6..ea94bbd8 100644 --- a/frontend/public/earth/js/constants.js +++ b/frontend/public/earth/js/constants.js @@ -60,6 +60,11 @@ export const SATELLITE_CONFIG = { apiPath: '/api/v1/visualization/geo/satellites' }; +export const PREDICTED_ORBIT_CONFIG = { + sampleInterval: 10, + opacity: 0.8 +}; + export const GRID_CONFIG = { latitudeStep: 10, longitudeStep: 30, diff --git a/frontend/public/earth/js/main.js b/frontend/public/earth/js/main.js index e126d1e3..4d7c287b 100644 --- a/frontend/public/earth/js/main.js +++ b/frontend/public/earth/js/main.js @@ -14,7 +14,7 @@ import { } from './ui.js'; import { createEarth, createClouds, createTerrain, createStars, createGridLines, toggleTerrain, getEarth } from './earth.js'; import { loadGeoJSONFromPath, loadLandingPoints, handleCableClick, clearCableSelection, getCableLines, getCablesById, lockedCable as cableLocked, getCableState, setCableState, clearAllCableStates, applyLandingPointVisualState, resetLandingPointVisualState, getAllLandingPoints, getShowCables } from './cables.js'; -import { createSatellites, loadSatellites, updateSatellitePositions, toggleSatellites, toggleTrails, getShowSatellites, selectSatellite, getSatelliteData, getSatellitePoints, setSatelliteRingState, updateLockedRingPosition, updateHoverRingPosition, getSatellitePositions } from './satellites.js'; +import { createSatellites, loadSatellites, updateSatellitePositions, toggleSatellites, toggleTrails, getShowSatellites, getSatelliteCount, selectSatellite, getSatelliteData, getSatellitePoints, setSatelliteRingState, updateLockedRingPosition, updateHoverRingPosition, getSatellitePositions, showPredictedOrbit, hidePredictedOrbit } from './satellites.js'; import { setupControls, getAutoRotate, getShowTerrain, zoomLevel, setAutoRotate, toggleAutoRotate, resetView } from './controls.js'; import { initInfoCard, showInfoCard, hideInfoCard, getCurrentType, setInfoCardNoBorder } from './info-card.js'; @@ -34,6 +34,7 @@ let dragStartTime = 0; let isLongDrag = false; export function clearLockedObject() { + hidePredictedOrbit(); hoveredCable = null; hoveredSatellite = null; hoveredSatelliteIndex = null; @@ -202,6 +203,7 @@ async function loadData(showWhiteSphere = false) { (async () => { const satCount = await loadSatellites(); console.log(`卫星数据加载完成: ${satCount} 颗`); + document.getElementById('satellite-count').textContent = satCount + ' 颗'; updateSatellitePositions(); console.log('卫星位置已更新'); toggleSatellites(true); @@ -438,6 +440,7 @@ function onClick(event, camera, renderer) { lockedObjectType = 'satellite'; lockedSatellite = sat; lockedSatelliteIndex = index; + showPredictedOrbit(sat); setAutoRotate(false); const satPositions = getSatellitePositions(); diff --git a/frontend/public/earth/js/satellites.js b/frontend/public/earth/js/satellites.js index 3420b478..5f0fc5be 100644 --- a/frontend/public/earth/js/satellites.js +++ b/frontend/public/earth/js/satellites.js @@ -18,7 +18,6 @@ let lockedRingSprite = null; const SATELLITE_API = SATELLITE_CONFIG.apiPath + '?limit=' + SATELLITE_CONFIG.maxCount; const MAX_SATELLITES = SATELLITE_CONFIG.maxCount; const TRAIL_LENGTH = SATELLITE_CONFIG.trailLength; -const TRAIL_MAX_AGE_MS = 5000; // 5 seconds const DOT_TEXTURE_SIZE = 32; function createCircularDotTexture() { @@ -136,15 +135,26 @@ function computeSatellitePosition(satellite, time) { const meanMotion = props.mean_motion || 15; const epoch = props.epoch || ''; - const year = epoch && epoch.length >= 4 ? parseInt(epoch.substring(0, 4)) : time.getUTCFullYear(); - const month = epoch && epoch.length >= 7 ? parseInt(epoch.substring(5, 7)) : time.getUTCMonth() + 1; - const day = epoch && epoch.length >= 10 ? parseInt(epoch.substring(8, 10)) : time.getUTCDate(); + // Simplified epoch calculation + let epochDate = epoch && epoch.length >= 10 ? new Date(epoch) : time; + 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).substring(2); - const tleLine1 = `1 ${String(noradId).padStart(5, '0')}U 00001A ${year}${String(month).padStart(2, '0')}${String(day).padStart(2, '0')}.00000000 .00000000 00000-0 00000-0 0 9999`; - const tleLine2 = `2 ${String(noradId).padStart(5, '0')} ${String(raan.toFixed(4)).padStart(8, ' ')} ${String(inclination.toFixed(4)).padStart(8, ' ')} ${String(eccentricity.toFixed(7)).replace('0.', '.')} ${String(argOfPerigee.toFixed(4)).padStart(8, ' ')} ${String(meanAnomaly.toFixed(4)).padStart(8, ' ')} ${String(meanMotion.toFixed(8)).padStart(11, ' ')} 0 9999`; + // Format eccentricity as "0.0001652" (7 chars after decimal) + const eccStr = '0' + eccentricity.toFixed(7); + const tleLine1 = `1 ${noradId.toString().padStart(5)}U 00001A ${epochStr} .00000000 00000-0 00000-0 0 9999`; + const tleLine2 = `2 ${noradId.toString().padStart(5)} ${raan.toFixed(4).padStart(8)} ${inclination.toFixed(4).padStart(8)} ${eccStr.substring(1)} ${argOfPerigee.toFixed(4).padStart(8)} ${meanAnomaly.toFixed(4).padStart(8)} ${meanMotion.toFixed(8).padStart(11)} 0 9999`; + console.log('[DEBUG computeSat] TLE1:', tleLine1); + console.log('[DEBUG computeSat] TLE2:', tleLine2); const satrec = twoline2satrec(tleLine1, tleLine2); + console.log('[DEBUG computeSat] satrec.error:', satrec?.error, 'satrec.no:', satrec?.no); if (!satrec || satrec.error) { + console.log('[DEBUG computeSat] returning null due to satrec error'); return null; } @@ -209,7 +219,7 @@ export async function loadSatellites() { satelliteData = data.features || []; console.log(`Loaded ${satelliteData.length} satellites`); - return satelliteData; + return satelliteData.length; } catch (error) { console.error('Failed to load satellites:', error); return []; @@ -245,10 +255,10 @@ export function updateSatellitePositions(deltaTime = 0) { satellitePositions[i].current.copy(pos); - satellitePositions[i].trail.push({ pos: pos.clone(), time: Date.now() }); - satellitePositions[i].trail = satellitePositions[i].trail.filter( - p => Date.now() - p.time < TRAIL_MAX_AGE_MS - ); + satellitePositions[i].trail.push(pos.clone()); + if (satellitePositions[i].trail.length > TRAIL_LENGTH) { + satellitePositions[i].trail.shift(); + } positions[i * 3] = pos.x; positions[i * 3 + 1] = pos.y; @@ -282,7 +292,7 @@ export function updateSatellitePositions(deltaTime = 0) { const trailIdx = (i * TRAIL_LENGTH + j) * 3; if (j < trail.length) { - const t = trail[j].pos; + const t = trail[j]; trailPositions[trailIdx] = t.x; trailPositions[trailIdx + 1] = t.y; trailPositions[trailIdx + 2] = t.z; @@ -455,3 +465,99 @@ export function initSatelliteScene(scene, earth) { sceneRef = scene; earthObjRef = earth; } + +let predictedOrbitLine = null; + +function calculateOrbitalPeriod(meanMotion) { + return 86400 / meanMotion; +} + +function calculatePredictedOrbit(satellite, periodSeconds, sampleInterval = 10) { + const points = []; + const samples = Math.ceil(periodSeconds / sampleInterval); + const now = new Date(); + + // Full orbit: from now to now+period (complete circle forward) + 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 we don't have enough points, use fallback orbit + if (points.length < samples * 0.5) { + points.length = 0; + const radius = CONFIG.earthRadius + 5; + const noradId = satellite.properties?.norad_cat_id || 0; + const inclination = satellite.properties?.inclination || 53; + const raan = satellite.properties?.raan || 0; + const meanAnomaly = satellite.properties?.mean_anomaly || 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(); + + const props = satellite.properties; + const meanMotion = props?.mean_motion || 15; + const periodSeconds = calculateOrbitalPeriod(meanMotion); + console.log('[DEBUG] meanMotion:', meanMotion, 'periodSeconds:', periodSeconds); + + // Test current time + const now = new Date(); + const testPos = computeSatellitePosition(satellite, now); + console.log('[DEBUG] testPos (now):', testPos); + + const points = calculatePredictedOrbit(satellite, periodSeconds); + console.log('[DEBUG] points.length:', points.length); + + if (points.length < 2) return; + + const positions = new Float32Array(points.length * 3); + const colors = new Float32Array(points.length * 3); + + for (let i = 0; i < points.length; i++) { + positions[i * 3] = points[i].x; + positions[i * 3 + 1] = points[i].y; + positions[i * 3 + 2] = points[i].z; + + const t = i / (points.length - 1); + colors[i * 3] = 1 - t * 0.4; + colors[i * 3 + 1] = 1 - t * 0.6; + colors[i * 3 + 2] = t; + } + + const geometry = new THREE.BufferGeometry(); + geometry.setAttribute('position', new THREE.BufferAttribute(positions, 3)); + geometry.setAttribute('color', new THREE.BufferAttribute(colors, 3)); + + const material = new THREE.LineBasicMaterial({ + vertexColors: true, + transparent: true, + opacity: 0.8, + blending: THREE.AdditiveBlending + }); + + predictedOrbitLine = new THREE.Line(geometry, material); + earthObjRef.add(predictedOrbitLine); +} + +export function hidePredictedOrbit() { + if (predictedOrbitLine) { + earthObjRef.remove(predictedOrbitLine); + predictedOrbitLine.geometry.dispose(); + predictedOrbitLine.material.dispose(); + predictedOrbitLine = null; + } +} diff --git a/planet.sh b/planet.sh index fc10341e..63c44dc4 100755 --- a/planet.sh +++ b/planet.sh @@ -25,13 +25,19 @@ start() { PYTHONPATH="$SCRIPT_DIR/backend" nohup python3 -m uvicorn app.main:app --host 0.0.0.0 --port 8000 --reload > /tmp/planet_backend.log 2>&1 & BACKEND_PID=$! - sleep 3 - - if ! curl -s http://localhost:8000/health > /dev/null 2>&1; then - echo -e "${RED}❌ 后端启动失败${NC}" - tail -10 /tmp/planet_backend.log - exit 1 - fi + echo " 等待后端启动..." + for i in {1..10}; do + sleep 2 + if curl -s http://localhost:8000/health > /dev/null 2>&1; then + echo -e " ${GREEN}✅ 后端已就绪${NC}" + break + fi + if [ $i -eq 10 ]; then + echo -e "${RED}❌ 后端启动失败${NC}" + tail -10 /tmp/planet_backend.log + exit 1 + fi + done echo -e "${BLUE}🌐 启动前端...${NC}" pkill -f "vite" 2>/dev/null || true