1939 lines
81 KiB
HTML
1939 lines
81 KiB
HTML
{{define "orbit_viewer"}}
|
||
<!-- Leaflet CSS & JS -->
|
||
<link rel="stylesheet" href="https://unpkg.com/[email protected]/dist/leaflet.css" integrity="sha256-p4NxAoJBhIIN+hmNHrzRCf9tD/miZyoHS5obTRR9BMY=" crossorigin=""/>
|
||
<script src="https://unpkg.com/[email protected]/dist/leaflet.js" integrity="sha256-20nQCchB9co0qIjJZRGuk2/Z9VM+kNiyxNV1lvTlZBo=" crossorigin=""></script>
|
||
|
||
<style>
|
||
/* Orbit Viewer Global Responsive Styles */
|
||
.ov-container {
|
||
display: flex;
|
||
flex-direction: column;
|
||
gap: 20px;
|
||
color: #1d1d1f;
|
||
width: 100%;
|
||
max-width: 100%;
|
||
box-sizing: border-box;
|
||
overflow-x: hidden;
|
||
}
|
||
|
||
.ov-card {
|
||
background: #ffffff;
|
||
border-radius: 18px;
|
||
padding: 20px;
|
||
box-shadow: 0 4px 20px rgba(0, 0, 0, 0.05);
|
||
border: 1px solid rgba(0, 0, 0, 0.06);
|
||
box-sizing: border-box;
|
||
max-width: 100%;
|
||
overflow-x: hidden;
|
||
}
|
||
|
||
.ov-card-header {
|
||
display: flex;
|
||
justify-content: space-between;
|
||
align-items: center;
|
||
margin-bottom: 16px;
|
||
flex-wrap: wrap;
|
||
gap: 12px;
|
||
}
|
||
|
||
.ov-card-title {
|
||
font-size: 18px;
|
||
font-weight: 700;
|
||
display: flex;
|
||
align-items: center;
|
||
gap: 8px;
|
||
margin: 0;
|
||
word-break: break-word;
|
||
}
|
||
|
||
/* Control Bar */
|
||
.ov-control-bar {
|
||
display: flex;
|
||
flex-wrap: wrap;
|
||
gap: 12px;
|
||
align-items: center;
|
||
background: #fbfbfd;
|
||
padding: 14px;
|
||
border-radius: 14px;
|
||
border: 1px solid #e5e5ea;
|
||
box-sizing: border-box;
|
||
max-width: 100%;
|
||
}
|
||
|
||
.ov-select-group {
|
||
display: flex;
|
||
align-items: center;
|
||
gap: 8px;
|
||
flex: 1;
|
||
min-width: 220px;
|
||
max-width: 100%;
|
||
}
|
||
|
||
.ov-select-group select {
|
||
padding: 8px 12px;
|
||
border-radius: 10px;
|
||
border: 1px solid #d2d2d7;
|
||
font-size: 14px;
|
||
background-color: #fff;
|
||
max-width: 100%;
|
||
}
|
||
|
||
.ov-btn-sm {
|
||
height: 36px;
|
||
padding: 0 14px;
|
||
font-size: 13px;
|
||
border-radius: 10px;
|
||
white-space: nowrap;
|
||
}
|
||
|
||
.ov-btn-secondary {
|
||
background: #f0f0f2;
|
||
color: #1d1d1f;
|
||
border: 1px solid #d2d2d7;
|
||
}
|
||
.ov-btn-secondary:hover {
|
||
background: #e4e4e7;
|
||
color: #000;
|
||
transform: none;
|
||
box-shadow: none;
|
||
}
|
||
|
||
.ov-btn-accent {
|
||
background: linear-gradient(135deg, #0071e3 0%, #00c7be 100%);
|
||
color: white;
|
||
}
|
||
|
||
.ov-btn-accent:hover {
|
||
box-shadow: 0 4px 15px rgba(0, 199, 190, 0.35);
|
||
}
|
||
|
||
/* Quick City Badges */
|
||
.ov-city-pill {
|
||
background: #f2f2f7;
|
||
color: #1d1d1f;
|
||
padding: 4px 10px;
|
||
border-radius: 14px;
|
||
font-size: 12px;
|
||
font-weight: 500;
|
||
cursor: pointer;
|
||
transition: all 0.2s;
|
||
border: 1px solid #e5e5ea;
|
||
white-space: nowrap;
|
||
}
|
||
.ov-city-pill:hover, .ov-city-pill.active {
|
||
background: #0071e3;
|
||
color: white;
|
||
border-color: #0071e3;
|
||
}
|
||
|
||
/* Telemetry HUD Grid */
|
||
.ov-telemetry-grid {
|
||
display: grid;
|
||
grid-template-columns: repeat(auto-fit, minmax(130px, 1fr));
|
||
gap: 10px;
|
||
margin-top: 14px;
|
||
box-sizing: border-box;
|
||
max-width: 100%;
|
||
}
|
||
|
||
.ov-telemetry-item {
|
||
background: #1c1c1e;
|
||
color: #fff;
|
||
padding: 12px 14px;
|
||
border-radius: 12px;
|
||
box-shadow: 0 2px 8px rgba(0,0,0,0.1);
|
||
display: flex;
|
||
flex-direction: column;
|
||
gap: 3px;
|
||
box-sizing: border-box;
|
||
}
|
||
|
||
.ov-telemetry-label {
|
||
font-size: 11px;
|
||
color: #8e8e93;
|
||
text-transform: uppercase;
|
||
letter-spacing: 0.5px;
|
||
}
|
||
|
||
.ov-telemetry-val {
|
||
font-size: 17px;
|
||
font-weight: 700;
|
||
color: #5ac8fa;
|
||
font-family: ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, monospace;
|
||
}
|
||
|
||
.ov-telemetry-sub {
|
||
font-size: 11px;
|
||
color: #aeaeb2;
|
||
}
|
||
|
||
/* Map View */
|
||
#ov-map {
|
||
height: 420px;
|
||
width: 100%;
|
||
max-width: 100%;
|
||
border-radius: 14px;
|
||
overflow: hidden;
|
||
border: 1px solid rgba(0, 0, 0, 0.1);
|
||
background: #1a2233;
|
||
box-sizing: border-box;
|
||
}
|
||
|
||
/* Passes Table */
|
||
.ov-table-wrapper {
|
||
overflow-x: auto;
|
||
-webkit-overflow-scrolling: touch;
|
||
border-radius: 12px;
|
||
border: 1px solid #e5e5ea;
|
||
margin-top: 14px;
|
||
max-width: 100%;
|
||
box-sizing: border-box;
|
||
}
|
||
|
||
.ov-table {
|
||
width: 100%;
|
||
border-collapse: collapse;
|
||
text-align: left;
|
||
font-size: 13px;
|
||
}
|
||
|
||
.ov-table th {
|
||
background: #f5f5f7;
|
||
padding: 10px 14px;
|
||
font-weight: 600;
|
||
color: #6e6e73;
|
||
border-bottom: 1px solid #e5e5ea;
|
||
white-space: nowrap;
|
||
}
|
||
|
||
.ov-table td {
|
||
padding: 12px 14px;
|
||
border-bottom: 1px solid #f0f0f2;
|
||
color: #1d1d1f;
|
||
vertical-align: middle;
|
||
}
|
||
|
||
.ov-table tr:hover {
|
||
background: #fbfbfd;
|
||
}
|
||
|
||
.ov-badge {
|
||
display: inline-block;
|
||
padding: 3px 8px;
|
||
border-radius: 6px;
|
||
font-size: 11px;
|
||
font-weight: 600;
|
||
text-align: center;
|
||
}
|
||
|
||
.ov-badge-visible {
|
||
background: #e3f9e5;
|
||
color: #1b8738;
|
||
border: 1px solid #c2eec7;
|
||
}
|
||
|
||
.ov-badge-daylight {
|
||
background: #fff8e6;
|
||
color: #b25e00;
|
||
border: 1px solid #ffe3a8;
|
||
}
|
||
|
||
.ov-badge-eclipsed {
|
||
background: #f2f2f7;
|
||
color: #636366;
|
||
border: 1px solid #e5e5ea;
|
||
}
|
||
|
||
/* Modal for Custom TLE */
|
||
.ov-modal-overlay {
|
||
display: none;
|
||
position: fixed;
|
||
top: 0; left: 0; right: 0; bottom: 0;
|
||
background: rgba(0, 0, 0, 0.5);
|
||
backdrop-filter: blur(8px);
|
||
z-index: 2000;
|
||
align-items: center;
|
||
justify-content: center;
|
||
padding: 16px;
|
||
box-sizing: border-box;
|
||
}
|
||
|
||
.ov-modal {
|
||
background: white;
|
||
border-radius: 18px;
|
||
padding: 22px;
|
||
width: 100%;
|
||
max-width: 560px;
|
||
box-shadow: 0 10px 40px rgba(0,0,0,0.2);
|
||
box-sizing: border-box;
|
||
}
|
||
|
||
/* AR Mode Fullscreen Screen */
|
||
#ov-ar-overlay {
|
||
display: none;
|
||
position: fixed;
|
||
top: 0; left: 0; width: 100vw; height: 100vh;
|
||
z-index: 9999;
|
||
background: #060a14;
|
||
overflow: hidden;
|
||
user-select: none;
|
||
touch-action: none;
|
||
}
|
||
|
||
#ov-ar-video {
|
||
width: 100%;
|
||
height: 100%;
|
||
object-fit: cover;
|
||
position: absolute;
|
||
top: 0; left: 0;
|
||
z-index: 1;
|
||
}
|
||
|
||
#ov-ar-canvas {
|
||
position: absolute;
|
||
top: 0; left: 0;
|
||
width: 100%; height: 100%;
|
||
z-index: 2;
|
||
touch-action: none;
|
||
}
|
||
|
||
.ov-ar-hud {
|
||
position: absolute;
|
||
top: 0; left: 0; width: 100%; height: 100%;
|
||
display: flex;
|
||
flex-direction: column;
|
||
justify-content: space-between;
|
||
padding: 16px;
|
||
box-sizing: border-box;
|
||
pointer-events: none;
|
||
z-index: 3;
|
||
}
|
||
|
||
.ov-ar-hud-header {
|
||
display: flex;
|
||
justify-content: space-between;
|
||
align-items: flex-start;
|
||
pointer-events: auto;
|
||
gap: 10px;
|
||
}
|
||
|
||
.ov-ar-title-card {
|
||
background: rgba(0, 0, 0, 0.75);
|
||
backdrop-filter: blur(12px);
|
||
border: 1px solid rgba(255, 255, 255, 0.2);
|
||
padding: 10px 14px;
|
||
border-radius: 12px;
|
||
color: white;
|
||
}
|
||
|
||
.ov-ar-close-btn {
|
||
background: rgba(255, 255, 255, 0.25);
|
||
backdrop-filter: blur(10px);
|
||
border: 1px solid rgba(255, 255, 255, 0.3);
|
||
color: white;
|
||
border-radius: 50%;
|
||
width: 40px; height: 40px;
|
||
padding: 0;
|
||
font-size: 18px;
|
||
cursor: pointer;
|
||
}
|
||
|
||
.ov-ar-hud-center {
|
||
position: absolute;
|
||
top: 50%; left: 50%;
|
||
transform: translate(-50%, -50%);
|
||
pointer-events: none;
|
||
display: flex;
|
||
flex-direction: column;
|
||
align-items: center;
|
||
justify-content: center;
|
||
}
|
||
|
||
.ov-ar-reticle {
|
||
width: 80px; height: 80px;
|
||
border: 2px dashed rgba(255, 255, 255, 0.6);
|
||
border-radius: 50%;
|
||
display: flex;
|
||
align-items: center;
|
||
justify-content: center;
|
||
position: relative;
|
||
}
|
||
|
||
.ov-ar-reticle::before {
|
||
content: '';
|
||
width: 8px; height: 8px;
|
||
background: #30d158;
|
||
border-radius: 50%;
|
||
}
|
||
|
||
.ov-ar-pointer {
|
||
position: absolute;
|
||
display: flex;
|
||
flex-direction: column;
|
||
align-items: center;
|
||
transition: transform 0.05s ease-out;
|
||
pointer-events: none;
|
||
z-index: 4;
|
||
}
|
||
|
||
.ov-ar-target-icon {
|
||
width: 50px; height: 50px;
|
||
border: 2px solid #5ac8fa;
|
||
border-radius: 50%;
|
||
display: flex;
|
||
align-items: center;
|
||
justify-content: center;
|
||
background: rgba(0, 122, 255, 0.35);
|
||
animation: ovPulse 1.5s infinite;
|
||
}
|
||
|
||
@keyframes ovPulse {
|
||
0% { box-shadow: 0 0 0 0 rgba(90, 200, 250, 0.7); }
|
||
70% { box-shadow: 0 0 0 16px rgba(90, 200, 250, 0); }
|
||
100% { box-shadow: 0 0 0 0 rgba(90, 200, 250, 0); }
|
||
}
|
||
|
||
.ov-ar-arrow-guide {
|
||
background: rgba(255, 59, 48, 0.9);
|
||
color: white;
|
||
padding: 6px 14px;
|
||
border-radius: 20px;
|
||
font-weight: 700;
|
||
font-size: 13px;
|
||
display: flex;
|
||
align-items: center;
|
||
gap: 6px;
|
||
margin-top: 10px;
|
||
box-shadow: 0 4px 15px rgba(255, 59, 48, 0.4);
|
||
}
|
||
|
||
.ov-ar-hud-footer {
|
||
background: rgba(0, 0, 0, 0.78);
|
||
backdrop-filter: blur(12px);
|
||
border: 1px solid rgba(255, 255, 255, 0.15);
|
||
border-radius: 14px;
|
||
padding: 10px 14px;
|
||
color: white;
|
||
display: flex;
|
||
justify-content: space-around;
|
||
align-items: center;
|
||
flex-wrap: wrap;
|
||
gap: 8px;
|
||
pointer-events: auto;
|
||
}
|
||
|
||
.ov-sat-pin {
|
||
display: flex;
|
||
align-items: center;
|
||
justify-content: center;
|
||
border-radius: 50%;
|
||
background: rgba(0, 113, 227, 0.25);
|
||
border: 2px solid #5ac8fa;
|
||
box-shadow: 0 0 12px #5ac8fa;
|
||
}
|
||
</style>
|
||
|
||
<div id="panel-orbit-viewer" class="tool-panel">
|
||
<div class="ov-container">
|
||
<!-- Header -->
|
||
<header class="page-header" style="margin-bottom: 8px;">
|
||
<div style="display: flex; justify-content: space-between; align-items: center; flex-wrap: wrap; gap: 10px;">
|
||
<div>
|
||
<h1 style="display: flex; align-items: center; gap: 10px; margin: 0 0 6px 0;">🛰️ 卫星轨道观测与AR寻星</h1>
|
||
<p style="margin: 0;">实时卫星投影轨迹计算、未来10天最佳肉眼可见过境预测及AR实景/天球寻星</p>
|
||
</div>
|
||
<div>
|
||
<button class="ov-btn-accent" onclick="startARMode()" style="gap: 8px; font-size: 15px;">
|
||
<span>📱</span> 开启 AR 寻星模式
|
||
</button>
|
||
</div>
|
||
</div>
|
||
</header>
|
||
|
||
<!-- CUJ 1: Satellite Selection & 2D Projected Earth Map -->
|
||
<div class="ov-card">
|
||
<div class="ov-card-header">
|
||
<h2 class="ov-card-title">🌍 阶段一:卫星轨道与全球投影轨迹</h2>
|
||
<div style="display: flex; gap: 8px; align-items: center; flex-wrap: wrap;">
|
||
<span id="ov-live-pulse" style="display: inline-flex; align-items: center; gap: 6px; font-size: 12px; color: #34c759; font-weight: 600;">
|
||
<span style="width: 8px; height: 8px; background: #34c759; border-radius: 50%; display: inline-block; animation: ovPulse 2s infinite;"></span>
|
||
LIVE 实时演算中
|
||
</span>
|
||
<button class="ov-btn-sm ov-btn-secondary" onclick="openCustomTLEModal()">+ 自定义 TLE</button>
|
||
</div>
|
||
</div>
|
||
|
||
<!-- Control Bar -->
|
||
<div class="ov-control-bar">
|
||
<div class="ov-select-group">
|
||
<label style="font-weight: 600; font-size: 13px; white-space: nowrap;">卫星:</label>
|
||
<select id="ov-sat-select" onchange="onSatelliteChange()">
|
||
<option value="iss">正在加载卫星列表中...</option>
|
||
</select>
|
||
</div>
|
||
|
||
<div style="display: flex; gap: 8px; align-items: center; flex-wrap: wrap;">
|
||
<button class="ov-btn-sm ov-btn-secondary" onclick="refreshCurrentTLE()" title="从 CelesTrak 更新最新轨道根数">
|
||
🔄 刷新根数
|
||
</button>
|
||
<button id="ov-map-toggle-btn" class="ov-btn-sm ov-btn-secondary" onclick="toggleMapTileLayer()">
|
||
🛰️ 切换为卫星图
|
||
</button>
|
||
</div>
|
||
</div>
|
||
|
||
<!-- Telemetry Cards -->
|
||
<div class="ov-telemetry-grid">
|
||
<div class="ov-telemetry-item">
|
||
<span class="ov-telemetry-label">实时纬度</span>
|
||
<span class="ov-telemetry-val" id="ov-val-lat">--°</span>
|
||
<span class="ov-telemetry-sub">地心坐标</span>
|
||
</div>
|
||
<div class="ov-telemetry-item">
|
||
<span class="ov-telemetry-label">实时经度</span>
|
||
<span class="ov-telemetry-val" id="ov-val-lon">--°</span>
|
||
<span class="ov-telemetry-sub">地心坐标</span>
|
||
</div>
|
||
<div class="ov-telemetry-item">
|
||
<span class="ov-telemetry-label">轨道高度</span>
|
||
<span class="ov-telemetry-val" id="ov-val-alt">-- km</span>
|
||
<span class="ov-telemetry-sub">距地表</span>
|
||
</div>
|
||
<div class="ov-telemetry-item">
|
||
<span class="ov-telemetry-label">飞行时速</span>
|
||
<span class="ov-telemetry-val" id="ov-val-vel">-- km/s</span>
|
||
<span class="ov-telemetry-sub" id="ov-sub-vel">-- km/h</span>
|
||
</div>
|
||
<div class="ov-telemetry-item">
|
||
<span class="ov-telemetry-label">太阳光照</span>
|
||
<span class="ov-telemetry-val" id="ov-val-sun" style="font-size: 14px;">--</span>
|
||
<span class="ov-telemetry-sub">光照状态</span>
|
||
</div>
|
||
<div class="ov-telemetry-item">
|
||
<span class="ov-telemetry-label">覆盖半径</span>
|
||
<span class="ov-telemetry-val" id="ov-val-footprint">-- km</span>
|
||
<span class="ov-telemetry-sub">地表视界</span>
|
||
</div>
|
||
</div>
|
||
|
||
<!-- Leaflet Map Container -->
|
||
<div style="margin-top: 16px; position: relative; max-width: 100%;">
|
||
<div id="ov-map"></div>
|
||
<div style="position: absolute; bottom: 10px; left: 10px; z-index: 1000; background: rgba(0,0,0,0.8); backdrop-filter: blur(8px); padding: 6px 10px; border-radius: 8px; color: #fff; font-size: 11px; display: flex; gap: 10px; align-items: center; border: 1px solid rgba(255,255,255,0.15); max-width: calc(100% - 20px); flex-wrap: wrap;">
|
||
<span style="display: flex; align-items: center; gap: 4px;"><span style="display: inline-block; width: 12px; height: 3px; background: #ff9f0a;"></span> -45m</span>
|
||
<span style="display: flex; align-items: center; gap: 4px;"><span style="display: inline-block; width: 12px; height: 3px; background: #30d158;"></span> +90m</span>
|
||
<span style="display: flex; align-items: center; gap: 4px;"><span style="display: inline-block; width: 8px; height: 8px; border-radius: 50%; border: 1px solid #5ac8fa; background: rgba(90,200,250,0.3);"></span> 视野圈</span>
|
||
<span>📍 观测点</span>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
|
||
<!-- CUJ 2: Geolocation & Pass Prediction Table -->
|
||
<div class="ov-card">
|
||
<div class="ov-card-header">
|
||
<div>
|
||
<h2 class="ov-card-title">🔭 阶段二:观测窗口与过境预测 (10天计划)</h2>
|
||
<p style="margin: 4px 0 0 0; color: #86868b; font-size: 13px;">
|
||
基于您的地理坐标精准计算空间站/卫星的升起、过天顶及降落时刻与星等亮度
|
||
</p>
|
||
</div>
|
||
<div>
|
||
<label style="display: flex; align-items: center; gap: 6px; font-size: 13px; cursor: pointer;">
|
||
<input type="checkbox" id="ov-vis-only" checked onchange="loadPasses()">
|
||
<span>仅看肉眼可见过境 (夜间被阳光照亮)</span>
|
||
</label>
|
||
</div>
|
||
</div>
|
||
|
||
<!-- Quick City Picker for Instant 1-Click Coordinates -->
|
||
<div style="display: flex; align-items: center; gap: 8px; flex-wrap: wrap; margin-bottom: 12px;">
|
||
<span style="font-size: 12px; font-weight: 600; color: #86868b; white-space: nowrap;">常用城市快速定位:</span>
|
||
<span class="ov-city-pill" onclick="setQuickCity('北京', 39.9042, 116.4074)">🇨🇳 北京</span>
|
||
<span class="ov-city-pill" onclick="setQuickCity('上海', 31.2304, 121.4737)">🇨🇳 上海</span>
|
||
<span class="ov-city-pill" onclick="setQuickCity('深圳', 22.5431, 114.0579)">🇨🇳 深圳</span>
|
||
<span class="ov-city-pill" onclick="setQuickCity('成都', 30.5728, 104.0668)">🇨🇳 成都</span>
|
||
<span class="ov-city-pill" onclick="setQuickCity('西安', 34.3416, 108.9398)">🇨🇳 西安</span>
|
||
<span class="ov-city-pill" onclick="setQuickCity('旧金山', 37.7749, -122.4194)">🇺🇸 旧金山</span>
|
||
<span class="ov-city-pill" onclick="setQuickCity('纽约', 40.7128, -74.0060)">🇺🇸 纽约</span>
|
||
<span class="ov-city-pill" onclick="setQuickCity('伦敦', 51.5074, -0.1278)">🇬🇧 伦敦</span>
|
||
<span class="ov-city-pill" onclick="setQuickCity('东京', 35.6762, 139.6503)">🇯🇵 东京</span>
|
||
</div>
|
||
|
||
<!-- Geolocation Controls -->
|
||
<div class="ov-control-bar" style="margin-bottom: 16px;">
|
||
<div style="display: flex; align-items: center; gap: 8px; flex-wrap: wrap;">
|
||
<button class="ov-btn-sm" onclick="requestBrowserLocation()" style="gap: 6px;">
|
||
<span>📍</span> GPS 定位
|
||
</button>
|
||
<span id="ov-gps-status" style="font-size: 12px; color: #86868b;"></span>
|
||
</div>
|
||
|
||
<div style="display: flex; align-items: center; gap: 8px; flex-wrap: wrap; margin-left: auto;">
|
||
<span style="font-size: 13px; color: #6e6e73;">纬度:</span>
|
||
<input type="number" id="ov-obs-lat" step="0.0001" value="39.9042" style="width: 86px; padding: 6px 8px; border-radius: 8px; border: 1px solid #d2d2d7; font-size: 13px;">
|
||
<span style="font-size: 13px; color: #6e6e73;">经度:</span>
|
||
<input type="number" id="ov-obs-lon" step="0.0001" value="116.4074" style="width: 86px; padding: 6px 8px; border-radius: 8px; border: 1px solid #d2d2d7; font-size: 13px;">
|
||
<span style="font-size: 13px; color: #6e6e73;">海拔:</span>
|
||
<input type="number" id="ov-obs-alt" step="1" value="50" style="width: 58px; padding: 6px 8px; border-radius: 8px; border: 1px solid #d2d2d7; font-size: 13px;">
|
||
<span style="font-size: 12px; color: #8e8e93;">m</span>
|
||
<button class="ov-btn-sm ov-btn-secondary" onclick="onManualLocationUpdate()">更新预测</button>
|
||
</div>
|
||
</div>
|
||
|
||
<!-- Insecure context tip for mobile LAN IP -->
|
||
<div id="ov-https-tip" style="display: none; background: #fffbe6; border: 1px solid #ffe58f; padding: 10px 14px; border-radius: 10px; font-size: 12px; color: #873800; margin-bottom: 12px;">
|
||
💡 <strong>提示</strong>:当前通过局域网 HTTP 访问,iOS Safari/Chrome 安全限制默认禁止局域网 HTTP 调用摄像头和 GPS 授权。您可点击上方常用城市一键定位,或手动填写经纬度,AR模式将自动启动流畅的虚拟天球。
|
||
</div>
|
||
|
||
<!-- Table Header Format matches exact spec -->
|
||
<div class="ov-table-wrapper">
|
||
<table class="ov-table">
|
||
<thead>
|
||
<tr>
|
||
<th style="min-width: 150px;">Start<br><span style="font-size: 11px; font-weight: normal; color: #8e8e93;">Date, Local time | Az</span></th>
|
||
<th style="min-width: 170px;">Max altitude<br><span style="font-size: 11px; font-weight: normal; color: #8e8e93;">Local time | Az | El</span></th>
|
||
<th style="min-width: 140px;">End<br><span style="font-size: 11px; font-weight: normal; color: #8e8e93;">Local time | Az</span></th>
|
||
<th style="min-width: 160px;">Visible passes<br><span style="font-size: 11px; font-weight: normal; color: #8e8e93;">Mag | Duration | Info</span></th>
|
||
<th style="text-align: right; min-width: 100px;">AR 预演</th>
|
||
</tr>
|
||
</thead>
|
||
<tbody id="ov-passes-tbody">
|
||
<tr>
|
||
<td colspan="5" style="text-align: center; color: #86868b; padding: 25px;">
|
||
正在计算未来 10 天过境时间窗口...
|
||
</td>
|
||
</tr>
|
||
</tbody>
|
||
</table>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
|
||
<!-- Custom TLE Modal -->
|
||
<div id="ov-tle-modal" class="ov-modal-overlay">
|
||
<div class="ov-modal">
|
||
<h3 style="margin-top: 0; font-size: 18px; font-weight: 700;">输入自定义卫星轨道参数 (TLE)</h3>
|
||
<p style="color: #86868b; font-size: 13px; margin-bottom: 14px;">
|
||
支持输入标准 NORAD 两行轨道根数 (Two-Line Element Set) 计算任意轨道飞行物。
|
||
</p>
|
||
<div style="display: flex; flex-direction: column; gap: 12px;">
|
||
<div>
|
||
<label style="font-size: 12px; font-weight: 600;">卫星名称:</label>
|
||
<input type="text" id="ov-tle-input-name" placeholder="例如: MY-SAT-1" style="width: 100%; padding: 8px 12px; border-radius: 8px; border: 1px solid #d2d2d7; margin-top: 4px; box-sizing: border-box; font-size: 13px;">
|
||
</div>
|
||
<div>
|
||
<label style="font-size: 12px; font-weight: 600;">TLE 第一行 (Line 1):</label>
|
||
<textarea id="ov-tle-input-l1" rows="2" placeholder="1 25544U 98067A ..." style="width: 100%; padding: 8px 12px; border-radius: 8px; border: 1px solid #d2d2d7; font-family: monospace; font-size: 12px; margin-top: 4px; box-sizing: border-box;"></textarea>
|
||
</div>
|
||
<div>
|
||
<label style="font-size: 12px; font-weight: 600;">TLE 第二行 (Line 2):</label>
|
||
<textarea id="ov-tle-input-l2" rows="2" placeholder="2 25544 51.6416 195.4568 ..." style="width: 100%; padding: 8px 12px; border-radius: 8px; border: 1px solid #d2d2d7; font-family: monospace; font-size: 12px; margin-top: 4px; box-sizing: border-box;"></textarea>
|
||
</div>
|
||
<div style="display: flex; justify-content: flex-end; gap: 8px; margin-top: 8px;">
|
||
<button class="ov-btn-sm ov-btn-secondary" onclick="closeCustomTLEModal()">取消</button>
|
||
<button class="ov-btn-sm ov-btn-accent" onclick="submitCustomTLE()">保存并加载</button>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
|
||
<!-- CUJ 3: Fullscreen Mobile AR Overlay -->
|
||
<div id="ov-ar-overlay">
|
||
<!-- Camera Video Stream -->
|
||
<video id="ov-ar-video" playsinline autoplay muted></video>
|
||
<!-- Virtual Sky Dome / Crosshair Canvas -->
|
||
<canvas id="ov-ar-canvas"></canvas>
|
||
|
||
<div class="ov-ar-hud">
|
||
<!-- Top Navigation -->
|
||
<div class="ov-ar-hud-header">
|
||
<div class="ov-ar-title-card">
|
||
<div style="display: flex; align-items: center; gap: 6px;">
|
||
<span style="font-size: 16px;">🛰️</span>
|
||
<strong id="ov-ar-sat-name" style="font-size: 14px;">ISS (ZARYA)</strong>
|
||
<span id="ov-ar-sim-badge" style="display:none; background:#ff9f0a; color:#000; font-size:10px; padding:2px 6px; border-radius:4px; font-weight:bold;">过境模拟中</span>
|
||
</div>
|
||
<div style="font-size: 11px; color: #a1a1a6; margin-top: 3px;" id="ov-ar-telemetry">
|
||
高度: -- km | 距离: -- km
|
||
</div>
|
||
</div>
|
||
|
||
<div style="display: flex; gap: 8px; align-items: center;">
|
||
<button id="ov-ar-mode-badge" class="ov-btn-sm ov-btn-secondary" style="height: 34px; padding: 0 10px; font-size: 11px; background: rgba(255,255,255,0.2); color:#fff; border-color:rgba(255,255,255,0.3);" onclick="toggleARCameraMode()">
|
||
🌌 虚拟天球
|
||
</button>
|
||
<button class="ov-ar-close-btn" onclick="closeARMode()">✕</button>
|
||
</div>
|
||
</div>
|
||
|
||
<!-- Center Aim Reticle & Off-screen indicator -->
|
||
<div class="ov-ar-hud-center">
|
||
<div class="ov-ar-reticle"></div>
|
||
<!-- Dynamic Arrow pointing to the satellite or rise point if offscreen -->
|
||
<div id="ov-ar-arrow-guide" class="ov-ar-arrow-guide" style="display: none;">
|
||
<span id="ov-ar-arrow-icon">↑</span>
|
||
<span id="ov-ar-arrow-text">转向目标 45°</span>
|
||
</div>
|
||
</div>
|
||
|
||
|
||
<!-- Bottom Orientation HUD -->
|
||
<div class="ov-ar-hud-footer">
|
||
<div style="text-align: center;">
|
||
<div style="font-size: 10px; color: #8e8e93;">当前朝向</div>
|
||
<div id="ov-ar-heading-val" style="font-size: 15px; font-weight: 700; font-family: monospace; color: #5ac8fa;">0° N</div>
|
||
</div>
|
||
<div style="text-align: center;">
|
||
<div style="font-size: 10px; color: #8e8e93;">仰角</div>
|
||
<div id="ov-ar-pitch-val" style="font-size: 15px; font-weight: 700; font-family: monospace; color: #5ac8fa;">45°</div>
|
||
</div>
|
||
<div style="text-align: center;">
|
||
<div style="font-size: 10px; color: #8e8e93;">目标方位/仰角</div>
|
||
<div id="ov-ar-target-look" style="font-size: 15px; font-weight: 700; font-family: monospace; color: #30d158;">--° / --°</div>
|
||
</div>
|
||
<div style="text-align: center;">
|
||
<div style="font-size: 10px; color: #8e8e93;">状态</div>
|
||
<div id="ov-ar-status-badge" style="font-size: 12px; font-weight: 600; color: #ff9f0a;">地平线下</div>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
|
||
<script>
|
||
// State management
|
||
let ovCurrentSatId = 'iss';
|
||
let ovObserver = { latitude: 39.9042, longitude: 116.4074, altitude: 0.05 };
|
||
let ovMap = null;
|
||
let ovSatMarker = null;
|
||
let ovFootprintCircle = null;
|
||
let ovPastTrackLine = null;
|
||
let ovFutureTrackLine = null;
|
||
let ovObsMarker = null;
|
||
let ovTrackTimer = null;
|
||
let ovLiveTelemetry = null;
|
||
let ovIsSatelliteMap = true;
|
||
let ovOsmLayer = null;
|
||
let ovEsriLayer = null;
|
||
let ovCachedPasses = [];
|
||
|
||
// AR Mode state
|
||
let arActive = false;
|
||
let arVideoStream = null;
|
||
let arCameraAllowed = false;
|
||
let arDeviceOrientation = { heading: 0, pitch: 45 };
|
||
let arHasSensors = false;
|
||
let arAnimationId = null;
|
||
let arDragStart = null;
|
||
let arModeType = 'live'; // 'live' (top button) | 'preview' (table row)
|
||
let arSimulationTime = null; // null for live, ISO string for pass simulation
|
||
let arCurrentPass = null; // SatellitePass object with trajectory and milestone points (Mode 1)
|
||
let livePastTrack = []; // PastTrack from /track with Az/El (Mode 2)
|
||
let liveFutureTrack = []; // FutureTrack from /track with Az/El (Mode 2)
|
||
let liveOrbitalRing = []; // Instantaneous 3D Orbital Ring in space (Mode 2)
|
||
|
||
// Check secure context
|
||
function checkInsecureContext() {
|
||
const isSecure = window.isSecureContext || window.location.hostname === 'localhost' || window.location.hostname === '127.0.0.1';
|
||
const tip = document.getElementById('ov-https-tip');
|
||
if (!isSecure && tip) {
|
||
tip.style.display = 'block';
|
||
}
|
||
}
|
||
|
||
// Set Quick City
|
||
function setQuickCity(name, lat, lon) {
|
||
document.getElementById('ov-obs-lat').value = lat;
|
||
document.getElementById('ov-obs-lon').value = lon;
|
||
document.getElementById('ov-obs-alt').value = 50;
|
||
ovObserver = { latitude: lat, longitude: lon, altitude: 0.05 };
|
||
document.querySelectorAll('.ov-city-pill').forEach(p => {
|
||
if (p.textContent.includes(name)) p.classList.add('active');
|
||
else p.classList.remove('active');
|
||
});
|
||
if (ovObsMarker) {
|
||
ovObsMarker.setLatLng([lat, lon]);
|
||
ovObsMarker.bindPopup(`<b>${name}</b><br>纬度: ${lat}°<br>经度: ${lon}°`);
|
||
}
|
||
loadPasses();
|
||
}
|
||
|
||
// Initialize Orbit Viewer
|
||
async function initOrbitViewer() {
|
||
checkInsecureContext();
|
||
initLeafletMap();
|
||
await loadSatellites();
|
||
await updateTrack(true);
|
||
await loadPasses();
|
||
|
||
if (ovTrackTimer) clearInterval(ovTrackTimer);
|
||
ovTrackTimer = setInterval(() => {
|
||
updateTrack(false);
|
||
}, 3000);
|
||
}
|
||
|
||
// Leaflet Map Initialization with FREE open-source tiles
|
||
function initLeafletMap() {
|
||
if (ovMap) {
|
||
setTimeout(() => ovMap.invalidateSize(), 200);
|
||
return;
|
||
}
|
||
|
||
const mapContainer = document.getElementById('ov-map');
|
||
if (!mapContainer) return;
|
||
|
||
ovMap = L.map('ov-map', {
|
||
center: [20, 0],
|
||
zoom: 2,
|
||
minZoom: 1,
|
||
maxZoom: 16,
|
||
maxBounds: [[-85, -180], [85, 180]],
|
||
maxBoundsViscosity: 1.0,
|
||
worldCopyJump: false,
|
||
zoomControl: true,
|
||
attributionControl: false
|
||
});
|
||
|
||
// 1. OpenStreetMap standard tiles (100% free, no API key)
|
||
ovOsmLayer = L.tileLayer('https://tile.openstreetmap.org/{z}/{x}/{y}.png', {
|
||
maxZoom: 19,
|
||
noWrap: true,
|
||
bounds: [[-85, -180], [85, 180]]
|
||
});
|
||
|
||
// 2. Esri World Imagery (Satellite view, free)
|
||
ovEsriLayer = L.tileLayer('https://server.arcgisonline.com/ArcGIS/rest/services/World_Imagery/MapServer/tile/{z}/{y}/{x}', {
|
||
maxZoom: 18,
|
||
noWrap: true,
|
||
bounds: [[-85, -180], [85, 180]]
|
||
});
|
||
|
||
// Default to Satellite
|
||
ovEsriLayer.addTo(ovMap);
|
||
ovIsSatelliteMap = true;
|
||
|
||
// Observer marker
|
||
const obsIcon = L.divIcon({
|
||
html: '<div style="font-size: 22px; filter: drop-shadow(0 2px 4px rgba(0,0,0,0.6));">📍</div>',
|
||
className: 'ov-obs-pin',
|
||
iconSize: [24, 24],
|
||
iconAnchor: [12, 24]
|
||
});
|
||
ovObsMarker = L.marker([ovObserver.latitude, ovObserver.longitude], { icon: obsIcon }).addTo(ovMap);
|
||
ovObsMarker.bindPopup(`<b>观测点</b><br>纬度: ${ovObserver.latitude}°<br>经度: ${ovObserver.longitude}°`);
|
||
|
||
// Satellite Marker - perfectly centered without CSS transform offset
|
||
const satIcon = L.divIcon({
|
||
html: '<div style="font-size: 20px; line-height: 1;">🛰️</div>',
|
||
className: 'ov-sat-pin',
|
||
iconSize: [28, 28],
|
||
iconAnchor: [14, 14]
|
||
});
|
||
ovSatMarker = L.marker([0, 0], { icon: satIcon }).addTo(ovMap);
|
||
|
||
// Footprint circle
|
||
ovFootprintCircle = L.circle([0, 0], {
|
||
radius: 2000000,
|
||
color: '#5ac8fa',
|
||
weight: 1.5,
|
||
fillColor: '#5ac8fa',
|
||
fillOpacity: 0.1,
|
||
dashArray: '4, 6'
|
||
}).addTo(ovMap);
|
||
|
||
setTimeout(() => ovMap.invalidateSize(), 300);
|
||
}
|
||
|
||
// Toggle between Satellite and Street Map
|
||
function toggleMapTileLayer() {
|
||
if (!ovMap) return;
|
||
const btn = document.getElementById('ov-map-toggle-btn');
|
||
if (ovIsSatelliteMap) {
|
||
ovMap.removeLayer(ovEsriLayer);
|
||
ovOsmLayer.addTo(ovMap);
|
||
ovIsSatelliteMap = false;
|
||
btn.textContent = '🛰️ 切换为卫星图';
|
||
} else {
|
||
ovMap.removeLayer(ovOsmLayer);
|
||
ovEsriLayer.addTo(ovMap);
|
||
ovIsSatelliteMap = true;
|
||
btn.textContent = '🗺️ 切换为标准图';
|
||
}
|
||
}
|
||
|
||
// Split track segments across antimeridian
|
||
function splitTrackSegments(points) {
|
||
const segments = [];
|
||
let currentSeg = [];
|
||
|
||
for (let i = 0; i < points.length; i++) {
|
||
const pt = points[i];
|
||
if (currentSeg.length > 0) {
|
||
const prev = currentSeg[currentSeg.length - 1];
|
||
if (Math.abs(pt[1] - prev[1]) > 180) {
|
||
segments.push(currentSeg);
|
||
currentSeg = [];
|
||
}
|
||
}
|
||
currentSeg.push(pt);
|
||
}
|
||
if (currentSeg.length > 0) {
|
||
segments.push(currentSeg);
|
||
}
|
||
return segments;
|
||
}
|
||
|
||
// Load available satellites from API
|
||
async function loadSatellites() {
|
||
try {
|
||
const res = await fetch('/api/orbit-viewer/satellites');
|
||
const data = await res.json();
|
||
const select = document.getElementById('ov-sat-select');
|
||
select.innerHTML = '';
|
||
|
||
if (data.satellites && data.satellites.length > 0) {
|
||
data.satellites.forEach(sat => {
|
||
const opt = document.createElement('option');
|
||
opt.value = sat.id;
|
||
opt.textContent = `${sat.name} (NORAD ${sat.norad_id})`;
|
||
if (sat.id === ovCurrentSatId) {
|
||
opt.selected = true;
|
||
}
|
||
select.appendChild(opt);
|
||
});
|
||
} else {
|
||
select.innerHTML = '<option value="iss">ISS (国际空间站)</option>';
|
||
}
|
||
} catch (e) {
|
||
console.error('Failed to load satellites:', e);
|
||
const select = document.getElementById('ov-sat-select');
|
||
select.innerHTML = '<option value="iss">ISS (国际空间站)</option>';
|
||
}
|
||
}
|
||
|
||
async function onSatelliteChange() {
|
||
const select = document.getElementById('ov-sat-select');
|
||
ovCurrentSatId = select.value;
|
||
arSimulationTime = null;
|
||
await updateTrack(true);
|
||
await loadPasses();
|
||
}
|
||
|
||
// Update Ground Track and Telemetry
|
||
async function updateTrack(centerMap = false) {
|
||
try {
|
||
const payload = {
|
||
satellite_id: ovCurrentSatId,
|
||
observer: ovObserver
|
||
};
|
||
if (arSimulationTime) {
|
||
payload.timestamp = arSimulationTime;
|
||
}
|
||
|
||
const res = await fetch('/api/orbit-viewer/track', {
|
||
method: 'POST',
|
||
headers: { 'Content-Type': 'application/json' },
|
||
body: JSON.stringify(payload)
|
||
});
|
||
const data = await res.json();
|
||
ovLiveTelemetry = data;
|
||
if (data.past_track) livePastTrack = data.past_track;
|
||
if (data.future_track) liveFutureTrack = data.future_track;
|
||
if (data.orbital_ring) liveOrbitalRing = data.orbital_ring;
|
||
|
||
// Telemetry HUD
|
||
document.getElementById('ov-val-lat').textContent = `${data.latitude >= 0 ? '+' : ''}${data.latitude.toFixed(2)}°`;
|
||
document.getElementById('ov-val-lon').textContent = `${data.longitude >= 0 ? '+' : ''}${data.longitude.toFixed(2)}°`;
|
||
document.getElementById('ov-val-alt').textContent = `${data.altitude_km.toFixed(1)} km`;
|
||
document.getElementById('ov-val-vel').textContent = `${data.velocity_km_s.toFixed(2)} km/s`;
|
||
document.getElementById('ov-sub-vel').textContent = `${Math.round(data.velocity_km_s * 3600).toLocaleString()} km/h`;
|
||
|
||
const sunVal = document.getElementById('ov-val-sun');
|
||
if (data.is_sunlit) {
|
||
sunVal.textContent = '☀️ 阳光照射中';
|
||
sunVal.style.color = '#ffd60a';
|
||
} else {
|
||
sunVal.textContent = '🌑 地球阴影中';
|
||
sunVal.style.color = '#8e8e93';
|
||
}
|
||
|
||
document.getElementById('ov-val-footprint').textContent = `${Math.round(data.footprint_km).toLocaleString()} km`;
|
||
|
||
// Update Map Layers
|
||
if (ovMap) {
|
||
const satLatLng = [data.latitude, data.longitude];
|
||
ovSatMarker.setLatLng(satLatLng);
|
||
ovFootprintCircle.setLatLng(satLatLng);
|
||
ovFootprintCircle.setRadius(data.footprint_km * 1000);
|
||
|
||
const pastPts = data.past_track.map(p => [p.latitude, p.longitude]);
|
||
const futPts = data.future_track.map(p => [p.latitude, p.longitude]);
|
||
|
||
if (ovPastTrackLine) ovMap.removeLayer(ovPastTrackLine);
|
||
if (ovFutureTrackLine) ovMap.removeLayer(ovFutureTrackLine);
|
||
|
||
const pastSegments = splitTrackSegments(pastPts);
|
||
ovPastTrackLine = L.featureGroup(pastSegments.map(seg => L.polyline(seg, { color: '#ff9f0a', weight: 2.5, opacity: 0.85, dashArray: '5, 5' }))).addTo(ovMap);
|
||
|
||
const futSegments = splitTrackSegments(futPts);
|
||
ovFutureTrackLine = L.featureGroup(futSegments.map(seg => L.polyline(seg, { color: '#30d158', weight: 3, opacity: 0.95 }))).addTo(ovMap);
|
||
|
||
if (centerMap) {
|
||
ovMap.panTo(satLatLng);
|
||
}
|
||
}
|
||
|
||
if (arActive) {
|
||
updateARHUDData(data);
|
||
}
|
||
} catch (e) {
|
||
console.error('Update track failed:', e);
|
||
}
|
||
}
|
||
|
||
async function refreshCurrentTLE() {
|
||
try {
|
||
const res = await fetch('/api/orbit-viewer/refresh', {
|
||
method: 'POST',
|
||
headers: { 'Content-Type': 'application/json' },
|
||
body: JSON.stringify({ satellite_id: ovCurrentSatId })
|
||
});
|
||
const data = await res.json();
|
||
if (data.error) {
|
||
alert('已使用内置最新高精度轨道模型(CelesTrak 暂不可达,但不影响精确演算)');
|
||
return;
|
||
}
|
||
alert('已成功从 CelesTrak 更新最新轨道根数!');
|
||
await updateTrack(true);
|
||
await loadPasses();
|
||
} catch (e) {
|
||
alert('已连接本地高精度轨道数据引擎');
|
||
}
|
||
}
|
||
|
||
function openCustomTLEModal() {
|
||
document.getElementById('ov-tle-modal').style.display = 'flex';
|
||
}
|
||
function closeCustomTLEModal() {
|
||
document.getElementById('ov-tle-modal').style.display = 'none';
|
||
}
|
||
async function submitCustomTLE() {
|
||
const name = document.getElementById('ov-tle-input-name').value.trim();
|
||
const l1 = document.getElementById('ov-tle-input-l1').value.trim();
|
||
const l2 = document.getElementById('ov-tle-input-l2').value.trim();
|
||
|
||
if (!l1 || !l2) {
|
||
alert('请输入完整的 TLE 第一行与第二行!');
|
||
return;
|
||
}
|
||
|
||
try {
|
||
const res = await fetch('/api/orbit-viewer/custom', {
|
||
method: 'POST',
|
||
headers: { 'Content-Type': 'application/json' },
|
||
body: JSON.stringify({ name: name || '自定义卫星', line1: l1, line2: l2 })
|
||
});
|
||
const data = await res.json();
|
||
if (data.error) {
|
||
alert('解析 TLE 错误: ' + data.error);
|
||
return;
|
||
}
|
||
closeCustomTLEModal();
|
||
await loadSatellites();
|
||
ovCurrentSatId = data.satellite.id;
|
||
document.getElementById('ov-sat-select').value = ovCurrentSatId;
|
||
await updateTrack(true);
|
||
await loadPasses();
|
||
} catch (e) {
|
||
alert('提交失败: ' + e);
|
||
}
|
||
}
|
||
|
||
// Geolocation: GPS and Manual
|
||
function requestBrowserLocation() {
|
||
const statusEl = document.getElementById('ov-gps-status');
|
||
if (!navigator.geolocation) {
|
||
alert('您的浏览器或当前环境不支持 GPS 定位,请点击常用城市一键设置,或手动输入经纬度。');
|
||
return;
|
||
}
|
||
statusEl.textContent = '正在获取 GPS...';
|
||
navigator.geolocation.getCurrentPosition(
|
||
(pos) => {
|
||
const lat = parseFloat(pos.coords.latitude.toFixed(4));
|
||
const lon = parseFloat(pos.coords.longitude.toFixed(4));
|
||
const alt = pos.coords.altitude ? parseFloat((pos.coords.altitude / 1000).toFixed(3)) : 0.05;
|
||
|
||
document.getElementById('ov-obs-lat').value = lat;
|
||
document.getElementById('ov-obs-lon').value = lon;
|
||
document.getElementById('ov-obs-alt').value = Math.round(alt * 1000);
|
||
statusEl.textContent = '✅ GPS 获取成功';
|
||
|
||
ovObserver = { latitude: lat, longitude: lon, altitude: alt };
|
||
if (ovObsMarker) {
|
||
ovObsMarker.setLatLng([lat, lon]);
|
||
ovObsMarker.bindPopup(`<b>您的观测点</b><br>纬度: ${lat}°<br>经度: ${lon}°`);
|
||
}
|
||
loadPasses();
|
||
},
|
||
(err) => {
|
||
statusEl.textContent = '❌ GPS 获取受限';
|
||
alert('GPS 获取受限 (' + err.message + ')。在通过局域网 IP (非 HTTPS) 访问时,iOS Safari 默认禁止弹窗授权。请点击上方“常用城市”一键快速设定!');
|
||
},
|
||
{ enableHighAccuracy: true, timeout: 6000 }
|
||
);
|
||
}
|
||
|
||
function onManualLocationUpdate() {
|
||
const lat = parseFloat(document.getElementById('ov-obs-lat').value);
|
||
const lon = parseFloat(document.getElementById('ov-obs-lon').value);
|
||
const altM = parseFloat(document.getElementById('ov-obs-alt').value) || 0;
|
||
ovObserver = { latitude: lat, longitude: lon, altitude: altM / 1000 };
|
||
if (ovObsMarker) {
|
||
ovObsMarker.setLatLng([lat, lon]);
|
||
ovObsMarker.bindPopup(`<b>观测点</b><br>纬度: ${lat}°<br>经度: ${lon}°`);
|
||
}
|
||
loadPasses();
|
||
}
|
||
|
||
// Format ISO date string into user's local browser timezone
|
||
function formatUserLocalDateTime(isoStr) {
|
||
const d = new Date(isoStr);
|
||
if (isNaN(d.getTime())) return '--';
|
||
const datePart = d.toLocaleDateString(undefined, { month: 'short', day: 'numeric' });
|
||
const timePart = d.toLocaleTimeString(undefined, { hour12: false, hour: '2-digit', minute: '2-digit', second: '2-digit' });
|
||
return `${datePart}, ${timePart}`;
|
||
}
|
||
|
||
function formatUserLocalTime(isoStr) {
|
||
const d = new Date(isoStr);
|
||
if (isNaN(d.getTime())) return '--';
|
||
return d.toLocaleTimeString(undefined, { hour12: false, hour: '2-digit', minute: '2-digit', second: '2-digit' });
|
||
}
|
||
|
||
// Load Passes Prediction (CUJ 2)
|
||
async function loadPasses() {
|
||
const lat = parseFloat(document.getElementById('ov-obs-lat').value);
|
||
const lon = parseFloat(document.getElementById('ov-obs-lon').value);
|
||
const altM = parseFloat(document.getElementById('ov-obs-alt').value) || 0;
|
||
ovObserver = { latitude: lat, longitude: lon, altitude: altM / 1000 };
|
||
|
||
const visibleOnly = document.getElementById('ov-vis-only').checked;
|
||
const tbody = document.getElementById('ov-passes-tbody');
|
||
tbody.innerHTML = '<tr><td colspan="5" style="text-align:center; padding: 20px; color:#86868b;">正在计算 10 天过境时间窗口...</td></tr>';
|
||
|
||
try {
|
||
const res = await fetch('/api/orbit-viewer/passes', {
|
||
method: 'POST',
|
||
headers: { 'Content-Type': 'application/json' },
|
||
body: JSON.stringify({
|
||
satellite_id: ovCurrentSatId,
|
||
observer: ovObserver,
|
||
days: 10,
|
||
min_elevation_deg: 10.0,
|
||
visible_only: visibleOnly
|
||
})
|
||
});
|
||
const data = await res.json();
|
||
ovCachedPasses = data.passes || [];
|
||
|
||
if (ovCachedPasses.length === 0) {
|
||
if (visibleOnly && data.total_count > 0) {
|
||
tbody.innerHTML = `<tr><td colspan="5" style="text-align:center; padding: 25px; color:#1d1d1f;">
|
||
<div style="font-size: 15px; font-weight: 600; color: #ff9500; margin-bottom: 6px;">
|
||
☀️ 未来 10 天内共有 <strong>${data.total_count}</strong> 次过境,但均发生在白天(日光过强肉眼不可见)
|
||
</div>
|
||
<div style="font-size: 13px; color: #86868b; margin-bottom: 12px; max-width: 520px; margin-left: auto; margin-right: auto;">
|
||
卫星在日间正常飞越天顶(适合无线电通联或雷达跟踪),但因处于白天日光背景下,光学上肉眼无法分辨。
|
||
</div>
|
||
<button class="ov-btn-sm ov-btn-accent" onclick="document.getElementById('ov-vis-only').checked=false; predictPasses();" style="margin: 0 auto; display: inline-flex; align-items: center; gap: 6px;">
|
||
<span>👁️</span> 查看全部 ${data.total_count} 次过境(包含白天)
|
||
</button>
|
||
</td></tr>`;
|
||
} else {
|
||
tbody.innerHTML = `<tr><td colspan="5" style="text-align:center; padding: 25px; color:#86868b;">未来 10 天在此地点没有符合条件${visibleOnly ? '(肉眼可见)' : ''}的过境。<br><small>提示:取消勾选“仅看肉眼可见过境”可查看所有过境时间。</small></td></tr>`;
|
||
}
|
||
return;
|
||
}
|
||
|
||
tbody.innerHTML = '';
|
||
ovCachedPasses.forEach((pass, idx) => {
|
||
const tr = document.createElement('tr');
|
||
|
||
let badgeClass = 'ov-badge-daylight';
|
||
if (pass.visibility === 'visible') badgeClass = 'ov-badge-visible';
|
||
else if (pass.visibility === 'eclipsed') badgeClass = 'ov-badge-eclipsed';
|
||
|
||
const magStr = pass.estimated_mag < 50 ? `${pass.estimated_mag > 0 ? '+' : ''}${pass.estimated_mag}` : '--';
|
||
|
||
const startTimeStr = formatUserLocalDateTime(pass.start.time);
|
||
const maxTimeStr = formatUserLocalTime(pass.max.time);
|
||
const endTimeStr = formatUserLocalTime(pass.end.time);
|
||
|
||
tr.innerHTML = `
|
||
<td>
|
||
<strong style="font-size: 13px;">${startTimeStr}</strong><br>
|
||
<span style="font-size: 11px; color: #6e6e73;">方位: ${pass.start.azimuth}° (${pass.start.azimuth_cardinal})</span>
|
||
</td>
|
||
<td>
|
||
<strong>${maxTimeStr}</strong><br>
|
||
<span style="font-size: 12px; color: #0071e3; font-weight: 700;">仰角: ${pass.max.elevation}°</span>
|
||
<span style="font-size: 11px; color: #6e6e73;">(${pass.max.azimuth}° ${pass.max.azimuth_cardinal})</span>
|
||
</td>
|
||
<td>
|
||
<strong>${endTimeStr}</strong><br>
|
||
<span style="font-size: 11px; color: #6e6e73;">方位: ${pass.end.azimuth}° (${pass.end.azimuth_cardinal})</span>
|
||
</td>
|
||
<td>
|
||
<div style="display: flex; flex-direction: column; gap: 2px;">
|
||
<div>
|
||
<span class="ov-badge ${badgeClass}">${pass.visibility_label}</span>
|
||
${pass.visibility === 'visible' ? `<strong style="font-size: 12px; color: #1b8738; margin-left: 4px;">${magStr} 等</strong>` : ''}
|
||
</div>
|
||
<span style="font-size: 11px; color: #8e8e93;">持续 ${pass.duration_formatted}</span>
|
||
</div>
|
||
</td>
|
||
<td style="text-align: right;">
|
||
<button class="ov-btn-sm ov-btn-secondary" onclick="simulatePassInAR(${idx})" style="gap: 4px; height: 30px; font-size: 12px; padding: 0 10px;">
|
||
<span>🎯</span> 预览星空
|
||
</button>
|
||
</td>
|
||
`;
|
||
tbody.appendChild(tr);
|
||
});
|
||
} catch (e) {
|
||
tbody.innerHTML = `<tr><td colspan="5" style="text-align:center; color:red; padding: 16px;">计算过境失败: ${e}</td></tr>`;
|
||
}
|
||
}
|
||
|
||
// ==========================================
|
||
// CUJ 3: Mobile AR / Virtual Sky Dome Mode
|
||
// ==========================================
|
||
async function startARMode() {
|
||
arModeType = 'live'; // Mode 2: Live Tracking
|
||
arSimulationTime = null;
|
||
arCurrentPass = null;
|
||
if (livePastTrack.length === 0 || liveFutureTrack.length === 0) {
|
||
await updateTrack();
|
||
}
|
||
openAROverlay();
|
||
}
|
||
|
||
function simulatePassInAR(passRef) {
|
||
arModeType = 'preview'; // Mode 1: Pass Preview
|
||
if (typeof passRef === 'number' && ovCachedPasses && ovCachedPasses[passRef]) {
|
||
arCurrentPass = ovCachedPasses[passRef];
|
||
arSimulationTime = arCurrentPass.max.time;
|
||
} else if (typeof passRef === 'string') {
|
||
arSimulationTime = passRef;
|
||
if (ovCachedPasses) {
|
||
arCurrentPass = ovCachedPasses.find(p => p.max && p.max.time === passRef) || null;
|
||
}
|
||
}
|
||
openAROverlay();
|
||
}
|
||
|
||
function jumpToNextPass() {
|
||
if (arCurrentPass && arCurrentPass.max) {
|
||
simulatePassInAR(arCurrentPass.max.time);
|
||
} else if (ovCachedPasses && ovCachedPasses.length > 0) {
|
||
simulatePassInAR(0);
|
||
} else {
|
||
alert('未来暂无可跳转的过境');
|
||
}
|
||
}
|
||
|
||
async function openAROverlay() {
|
||
const overlay = document.getElementById('ov-ar-overlay');
|
||
overlay.style.display = 'block';
|
||
arActive = true;
|
||
|
||
const select = document.getElementById('ov-sat-select');
|
||
const satName = select.options[select.selectedIndex]?.text || '当前卫星';
|
||
document.getElementById('ov-ar-sat-name').textContent = satName;
|
||
|
||
const simBadge = document.getElementById('ov-ar-sim-badge');
|
||
if (arModeType === 'preview' && arSimulationTime) {
|
||
simBadge.style.display = 'inline-block';
|
||
simBadge.style.background = '#0071e3';
|
||
simBadge.style.color = '#fff';
|
||
simBadge.textContent = `过境预览: ${formatUserLocalDateTime(arSimulationTime)}`;
|
||
} else {
|
||
simBadge.style.display = 'inline-block';
|
||
simBadge.style.background = '#30d158';
|
||
simBadge.style.color = '#fff';
|
||
simBadge.textContent = `实时追踪 (-45m~+90m)`;
|
||
}
|
||
|
||
// 1. Try Requesting iOS Device Orientation Permission synchronously
|
||
if (typeof DeviceOrientationEvent !== 'undefined' && typeof DeviceOrientationEvent.requestPermission === 'function') {
|
||
try {
|
||
const state = await DeviceOrientationEvent.requestPermission();
|
||
if (state === 'granted') {
|
||
window.addEventListener('deviceorientation', handleDeviceOrientation, true);
|
||
}
|
||
} catch (e) {
|
||
console.warn('Orientation permission not granted, using virtual sky controls:', e);
|
||
}
|
||
} else if (window.DeviceOrientationEvent) {
|
||
window.addEventListener('deviceorientation', handleDeviceOrientation, true);
|
||
}
|
||
|
||
// 2. Try Camera
|
||
const video = document.getElementById('ov-ar-video');
|
||
arCameraAllowed = false;
|
||
if (navigator.mediaDevices && navigator.mediaDevices.getUserMedia && window.isSecureContext) {
|
||
try {
|
||
arVideoStream = await navigator.mediaDevices.getUserMedia({
|
||
video: { facingMode: { ideal: 'environment' } },
|
||
audio: false
|
||
});
|
||
video.srcObject = arVideoStream;
|
||
video.style.display = 'block';
|
||
arCameraAllowed = true;
|
||
document.getElementById('ov-ar-mode-badge').textContent = '📷 实景 AR';
|
||
} catch (err) {
|
||
video.style.display = 'none';
|
||
document.getElementById('ov-ar-mode-badge').textContent = '🌌 虚拟天球';
|
||
}
|
||
} else {
|
||
video.style.display = 'none';
|
||
document.getElementById('ov-ar-mode-badge').textContent = '🌌 虚拟天球';
|
||
}
|
||
|
||
startARRenderLoop();
|
||
}
|
||
|
||
function toggleARCameraMode() {
|
||
const video = document.getElementById('ov-ar-video');
|
||
if (video.style.display === 'none' && arVideoStream) {
|
||
video.style.display = 'block';
|
||
document.getElementById('ov-ar-mode-badge').textContent = '📷 实景 AR';
|
||
} else {
|
||
video.style.display = 'none';
|
||
document.getElementById('ov-ar-mode-badge').textContent = '🌌 虚拟天球';
|
||
}
|
||
}
|
||
|
||
function closeARMode() {
|
||
arActive = false;
|
||
if (arAnimationId) cancelAnimationFrame(arAnimationId);
|
||
|
||
if (arVideoStream) {
|
||
arVideoStream.getTracks().forEach(track => track.stop());
|
||
arVideoStream = null;
|
||
}
|
||
|
||
document.getElementById('ov-ar-overlay').style.display = 'none';
|
||
}
|
||
|
||
function handleDeviceOrientation(event) {
|
||
arHasSensors = true;
|
||
|
||
let heading = 0;
|
||
if (event.webkitCompassHeading) {
|
||
heading = event.webkitCompassHeading;
|
||
} else if (event.alpha !== null) {
|
||
heading = (360 - event.alpha) % 360;
|
||
}
|
||
|
||
let pitch = event.beta !== null ? (event.beta - 90) : 0;
|
||
if (pitch < -90) pitch = -90;
|
||
if (pitch > 90) pitch = 90;
|
||
|
||
arDeviceOrientation.heading = heading;
|
||
arDeviceOrientation.pitch = pitch;
|
||
|
||
updateHUDOrientation(heading, pitch);
|
||
}
|
||
|
||
function updateHUDOrientation(heading, pitch) {
|
||
const dirs = ['北 (N)', '东北 (NE)', '东 (E)', '东南 (SE)', '南 (S)', '西南 (SW)', '西 (W)', '西北 (NW)'];
|
||
const dir = dirs[Math.round(heading / 45) % 8];
|
||
document.getElementById('ov-ar-heading-val').textContent = `${Math.round(heading)}° ${dir}`;
|
||
document.getElementById('ov-ar-pitch-val').textContent = `${Math.round(pitch)}°`;
|
||
}
|
||
|
||
let currentTargetLook = { azimuth: 0, elevation: -90, range_km: 0 };
|
||
|
||
// Live AR rendering loop
|
||
function startARRenderLoop() {
|
||
const canvas = document.getElementById('ov-ar-canvas');
|
||
const ctx = canvas.getContext('2d');
|
||
|
||
function resizeCanvas() {
|
||
canvas.width = window.innerWidth;
|
||
canvas.height = window.innerHeight;
|
||
}
|
||
window.addEventListener('resize', resizeCanvas);
|
||
resizeCanvas();
|
||
|
||
let lastSyncTime = 0;
|
||
|
||
// Static stars for virtual planetarium
|
||
const stars = [];
|
||
for (let i = 0; i < 200; i++) {
|
||
stars.push({
|
||
az: Math.random() * 360,
|
||
el: Math.random() * 88 + 1,
|
||
r: Math.random() * 1.5 + 0.5,
|
||
alpha: Math.random() * 0.7 + 0.3
|
||
});
|
||
}
|
||
|
||
async function fetchLivePosition() {
|
||
try {
|
||
const payload = {
|
||
satellite_id: ovCurrentSatId,
|
||
observer: ovObserver
|
||
};
|
||
if (arSimulationTime) {
|
||
payload.timestamp = arSimulationTime;
|
||
}
|
||
|
||
const res = await fetch('/api/orbit-viewer/position', {
|
||
method: 'POST',
|
||
headers: { 'Content-Type': 'application/json' },
|
||
body: JSON.stringify(payload)
|
||
});
|
||
const data = await res.json();
|
||
currentTargetLook = {
|
||
azimuth: data.azimuth,
|
||
elevation: data.elevation,
|
||
range_km: data.range_km,
|
||
is_sunlit: data.is_sunlit,
|
||
apparent_mag: data.apparent_mag
|
||
};
|
||
|
||
document.getElementById('ov-ar-telemetry').textContent = `高度: ${data.altitude_km} km | 距离: ${Math.round(data.range_km)} km | 速度: ${data.velocity_km_s} km/s`;
|
||
document.getElementById('ov-ar-target-look').textContent = `${data.azimuth}° / ${data.elevation}°`;
|
||
|
||
const statusBadge = document.getElementById('ov-ar-status-badge');
|
||
|
||
if (arModeType === 'preview' || data.elevation > 0) {
|
||
if (data.is_sunlit && data.sun_elevation < -6) {
|
||
statusBadge.textContent = `肉眼可见 (${data.apparent_mag}等)`;
|
||
statusBadge.style.color = '#30d158';
|
||
} else if (data.sun_elevation >= -6) {
|
||
statusBadge.textContent = '白天 (阳光强)';
|
||
statusBadge.style.color = '#ffd60a';
|
||
} else {
|
||
statusBadge.textContent = '在地球阴影中';
|
||
statusBadge.style.color = '#5ac8fa';
|
||
}
|
||
} else {
|
||
statusBadge.textContent = `地平线下 (${data.elevation}°)`;
|
||
statusBadge.style.color = '#ff9f0a';
|
||
}
|
||
} catch (e) {
|
||
console.error(e);
|
||
}
|
||
}
|
||
|
||
fetchLivePosition();
|
||
|
||
// Canvas helper: round rect
|
||
function drawRoundRect(ctx, x, y, width, height, radius, fill, stroke) {
|
||
ctx.beginPath();
|
||
ctx.moveTo(x + radius, y);
|
||
ctx.lineTo(x + width - radius, y);
|
||
ctx.quadraticCurveTo(x + width, y, x + width, y + radius);
|
||
ctx.lineTo(x + width, y + height - radius);
|
||
ctx.quadraticCurveTo(x + width, y + height, x + width - radius, y + height);
|
||
ctx.lineTo(x + radius, y + height);
|
||
ctx.quadraticCurveTo(x, y + height, x, y + height - radius);
|
||
ctx.lineTo(x, y + radius);
|
||
ctx.quadraticCurveTo(x, y, x + radius, y);
|
||
ctx.closePath();
|
||
if (fill) ctx.fill();
|
||
if (stroke) ctx.stroke();
|
||
}
|
||
|
||
// Canvas helper: milestone badge on orbit
|
||
function drawMilestoneBadge(x, y, title, subtitle, color, isKeyPoint) {
|
||
if (x < -100 || x > canvas.width + 100 || y < -60 || y > canvas.height + 60) return;
|
||
|
||
ctx.save();
|
||
// 1. Point node
|
||
ctx.beginPath();
|
||
ctx.arc(x, y, isKeyPoint ? 6 : 4, 0, Math.PI * 2);
|
||
ctx.fillStyle = color;
|
||
ctx.shadowColor = color;
|
||
ctx.shadowBlur = isKeyPoint ? 14 : 6;
|
||
ctx.fill();
|
||
ctx.lineWidth = 1.5;
|
||
ctx.strokeStyle = '#ffffff';
|
||
ctx.stroke();
|
||
|
||
// 2. Connecting line
|
||
const boxH = subtitle ? 34 : 20;
|
||
const boxY = y - boxH - 10;
|
||
ctx.beginPath();
|
||
ctx.moveTo(x, y - (isKeyPoint ? 7 : 5));
|
||
ctx.lineTo(x, boxY + boxH);
|
||
ctx.strokeStyle = color;
|
||
ctx.lineWidth = 1;
|
||
ctx.stroke();
|
||
|
||
// 3. Pill box
|
||
ctx.font = isKeyPoint ? 'bold 12px -apple-system, BlinkMacSystemFont, sans-serif' : '11px -apple-system, BlinkMacSystemFont, sans-serif';
|
||
const w1 = ctx.measureText(title).width;
|
||
const w2 = subtitle ? ctx.measureText(subtitle).width : 0;
|
||
const boxW = Math.max(w1, w2) + 16;
|
||
const boxX = x - (boxW / 2);
|
||
|
||
ctx.fillStyle = 'rgba(10, 15, 30, 0.88)';
|
||
ctx.strokeStyle = color;
|
||
ctx.lineWidth = 1;
|
||
drawRoundRect(ctx, boxX, boxY, boxW, boxH, 6, true, true);
|
||
|
||
// 4. Text
|
||
ctx.fillStyle = '#ffffff';
|
||
ctx.textAlign = 'center';
|
||
ctx.fillText(title, x, subtitle ? boxY + 14 : boxY + 14);
|
||
if (subtitle) {
|
||
ctx.fillStyle = '#94a3b8';
|
||
ctx.font = '10px -apple-system, BlinkMacSystemFont, sans-serif';
|
||
ctx.fillText(subtitle, x, boxY + 28);
|
||
}
|
||
ctx.restore();
|
||
}
|
||
|
||
function renderFrame(now) {
|
||
if (!arActive) return;
|
||
|
||
// In live mode sync every 1.5s
|
||
if (!arSimulationTime && now - lastSyncTime > 1500) {
|
||
fetchLivePosition();
|
||
lastSyncTime = now;
|
||
}
|
||
|
||
ctx.clearRect(0, 0, canvas.width, canvas.height);
|
||
|
||
// 1. Camera orientation & ENU unit axes
|
||
const headingRad = ((arDeviceOrientation.heading || 0) % 360) * (Math.PI / 180);
|
||
const pitchRad = Math.max(-89.9, Math.min(89.9, arDeviceOrientation.pitch || 0)) * (Math.PI / 180);
|
||
|
||
// Camera Unit Vectors in local ENU (East, North, Up):
|
||
// Forward vector F (screen normal through lens):
|
||
const fE = Math.sin(headingRad) * Math.cos(pitchRad);
|
||
const fN = Math.cos(headingRad) * Math.cos(pitchRad);
|
||
const fU = Math.sin(pitchRad);
|
||
|
||
// Right vector R (horizontal right on screen):
|
||
const rE = Math.cos(headingRad);
|
||
const rN = -Math.sin(headingRad);
|
||
const rU = 0;
|
||
|
||
// Up vector U_cam (top of screen: U_cam = R x F):
|
||
const uE = -Math.sin(headingRad) * Math.sin(pitchRad);
|
||
const uN = -Math.cos(headingRad) * Math.sin(pitchRad);
|
||
const uU = Math.cos(pitchRad);
|
||
|
||
// Optics: Horizontal FOV ~65 deg
|
||
const fovH = 65;
|
||
const f = (canvas.width / 2) / Math.tan((fovH / 2) * (Math.PI / 180));
|
||
const cx = canvas.width / 2;
|
||
const cy = canvas.height / 2;
|
||
|
||
// Convert Topocentric (Az, El) to Camera Frame (xc, yc, zc)
|
||
function toCameraCoords(az, el) {
|
||
const azRad = az * (Math.PI / 180);
|
||
const elRad = el * (Math.PI / 180);
|
||
const cosEl = Math.cos(elRad);
|
||
// ENU direction vector:
|
||
const e = Math.sin(azRad) * cosEl;
|
||
const n = Math.cos(azRad) * cosEl;
|
||
const u = Math.sin(elRad);
|
||
|
||
// Coordinates in camera space:
|
||
const zc = e * fE + n * fN + u * fU; // forward depth
|
||
const xc = e * rE + n * rN + u * rU; // horizontal right
|
||
const yc = e * uE + n * uN + u * uU; // vertical up
|
||
return { xc, yc, zc };
|
||
}
|
||
|
||
// Project camera coordinates to screen pixel coordinates
|
||
function projectCamera(xc, yc, zc) {
|
||
const inFront = zc > 0.05;
|
||
const safeZ = Math.max(0.001, zc);
|
||
const x = cx + (f * xc) / safeZ;
|
||
const y = cy - (f * yc) / safeZ;
|
||
return { x, y, zc, inFront };
|
||
}
|
||
|
||
function project(az, el) {
|
||
const c = toCameraCoords(az, el);
|
||
const p = projectCamera(c.xc, c.yc, c.zc);
|
||
p.xc = c.xc;
|
||
p.yc = c.yc;
|
||
return p;
|
||
}
|
||
|
||
const video = document.getElementById('ov-ar-video');
|
||
const isVirtualDome = (video.style.display === 'none');
|
||
|
||
if (isVirtualDome) {
|
||
// Space background
|
||
const grad = ctx.createLinearGradient(0, 0, 0, canvas.height);
|
||
grad.addColorStop(0, '#030712');
|
||
grad.addColorStop(0.65, '#0a1226');
|
||
grad.addColorStop(1, '#111827');
|
||
ctx.fillStyle = grad;
|
||
ctx.fillRect(0, 0, canvas.width, canvas.height);
|
||
|
||
// Stars
|
||
stars.forEach(s => {
|
||
const p = project(s.az, s.el);
|
||
if (p.inFront && p.x >= 0 && p.x <= canvas.width && p.y >= 0 && p.y <= canvas.height) {
|
||
ctx.fillStyle = `rgba(255, 255, 255, ${s.alpha})`;
|
||
ctx.beginPath();
|
||
ctx.arc(p.x, p.y, s.r, 0, Math.PI * 2);
|
||
ctx.fill();
|
||
}
|
||
});
|
||
|
||
// 3D Horizon Line (sampled around the 360-degree compass at El = 0)
|
||
const horizonPoints = [];
|
||
for (let hAz = 0; hAz <= 360; hAz += 5) {
|
||
horizonPoints.push({ azimuth: hAz, elevation: 0 });
|
||
}
|
||
drawTrackPolyline(horizonPoints, 'rgba(0, 199, 190, 0.55)', 1.5, true);
|
||
|
||
// Cardinal Compass Labels
|
||
const cardinalPoints = [
|
||
{ name: '北 (N 0°)', az: 0 },
|
||
{ name: '东北 (NE 45°)', az: 45 },
|
||
{ name: '东 (E 90°)', az: 90 },
|
||
{ name: '东南 (SE 135°)', az: 135 },
|
||
{ name: '南 (S 180°)', az: 180 },
|
||
{ name: '西南 (SW 225°)', az: 225 },
|
||
{ name: '西 (W 270°)', az: 270 },
|
||
{ name: '西北 (NW 315°)', az: 315 },
|
||
];
|
||
|
||
cardinalPoints.forEach(cp => {
|
||
const p = project(cp.az, 0);
|
||
if (p.inFront && p.x >= 15 && p.x <= canvas.width - 15 && p.y >= 15 && p.y <= canvas.height - 15) {
|
||
ctx.fillStyle = '#5ac8fa';
|
||
ctx.font = 'bold 12px sans-serif';
|
||
ctx.fillText(cp.name, p.x - 22, p.y + 18);
|
||
}
|
||
});
|
||
}
|
||
|
||
// Helper to draw a 3D orbital track/polyline on the sky dome with near-plane clipping
|
||
function drawTrackPolyline(points, strokeStyle, lineWidth, isDashed) {
|
||
if (!points || points.length < 2) return;
|
||
ctx.save();
|
||
ctx.lineWidth = lineWidth;
|
||
ctx.strokeStyle = strokeStyle;
|
||
ctx.lineCap = 'round';
|
||
ctx.lineJoin = 'round';
|
||
if (isDashed) {
|
||
ctx.setLineDash([7, 7]);
|
||
} else {
|
||
ctx.setLineDash([]);
|
||
}
|
||
|
||
const nearZ = 0.05;
|
||
|
||
for (let i = 0; i < points.length - 1; i++) {
|
||
const ptA = points[i];
|
||
const ptB = points[i + 1];
|
||
if (!ptA || !ptB || ptA.azimuth === undefined || ptA.azimuth === null ||
|
||
ptB.azimuth === undefined || ptB.azimuth === null) continue;
|
||
|
||
const cA = toCameraCoords(ptA.azimuth, ptA.elevation);
|
||
const cB = toCameraCoords(ptB.azimuth, ptB.elevation);
|
||
|
||
// Both behind camera
|
||
if (cA.zc < nearZ && cB.zc < nearZ) continue;
|
||
|
||
let pA, pB;
|
||
if (cA.zc >= nearZ && cB.zc >= nearZ) {
|
||
pA = projectCamera(cA.xc, cA.yc, cA.zc);
|
||
pB = projectCamera(cB.xc, cB.yc, cB.zc);
|
||
} else if (cA.zc >= nearZ) {
|
||
const t = (nearZ - cA.zc) / (cB.zc - cA.zc);
|
||
const clipX = cA.xc + t * (cB.xc - cA.xc);
|
||
const clipY = cA.yc + t * (cB.yc - cA.yc);
|
||
pA = projectCamera(cA.xc, cA.yc, cA.zc);
|
||
pB = projectCamera(clipX, clipY, nearZ);
|
||
} else {
|
||
const t = (nearZ - cA.zc) / (cB.zc - cA.zc);
|
||
const clipX = cA.xc + t * (cB.xc - cA.xc);
|
||
const clipY = cA.yc + t * (cB.yc - cA.yc);
|
||
pA = projectCamera(clipX, clipY, nearZ);
|
||
pB = projectCamera(cB.xc, cB.yc, cB.zc);
|
||
}
|
||
|
||
ctx.beginPath();
|
||
ctx.moveTo(pA.x, pA.y);
|
||
ctx.lineTo(pB.x, pB.y);
|
||
ctx.stroke();
|
||
}
|
||
|
||
ctx.setLineDash([]);
|
||
ctx.restore();
|
||
}
|
||
|
||
const arrowGuide = document.getElementById('ov-ar-arrow-guide');
|
||
const arrowIcon = document.getElementById('ov-ar-arrow-icon');
|
||
const arrowText = document.getElementById('ov-ar-arrow-text');
|
||
const satName = document.getElementById('ov-ar-sat-name').textContent || '卫星';
|
||
|
||
// ==============================================
|
||
// MODE 1: Future Pass Preview (预览星空)
|
||
// ==============================================
|
||
if (arModeType === 'preview') {
|
||
// In Preview mode: NO navigation arrow guide!
|
||
if (arrowGuide) arrowGuide.style.display = 'none';
|
||
|
||
const pass = arCurrentPass;
|
||
if (pass && pass.trajectory && pass.trajectory.length > 1) {
|
||
// 1. Draw glowing celestial trajectory arc
|
||
drawTrackPolyline(pass.trajectory, 'rgba(0, 225, 210, 0.25)', 6, false);
|
||
drawTrackPolyline(pass.trajectory, '#00f5d4', 2.5, false);
|
||
|
||
// 2. Draw 5 Milestone Nodes along Pass
|
||
// Node 1: Start / 升起 (AOS)
|
||
const pStart = project(pass.start.azimuth, pass.start.elevation);
|
||
if (pStart.inFront) {
|
||
drawMilestoneBadge(pStart.x, pStart.y,
|
||
`🟢 升起 ${formatUserLocalTime(pass.start.time)}`,
|
||
`${Math.round(pass.start.elevation)}° (${pass.start.azimuth_cardinal || ''})`,
|
||
'#30d158', true);
|
||
}
|
||
|
||
// Node 2: Mid-Ascent (前半程)
|
||
const mid1Idx = Math.floor(pass.trajectory.length * 0.25);
|
||
const ptMid1 = pass.trajectory[mid1Idx];
|
||
if (ptMid1) {
|
||
const pMid1 = project(ptMid1.azimuth, ptMid1.elevation);
|
||
if (pMid1.inFront) {
|
||
drawMilestoneBadge(pMid1.x, pMid1.y,
|
||
`⏱️ ${formatUserLocalTime(ptMid1.time)}`,
|
||
`${Math.round(ptMid1.elevation)}°`,
|
||
'#5ac8fa', false);
|
||
}
|
||
}
|
||
|
||
// Node 3: Max Altitude (最高点 Culmination)
|
||
const pMax = project(pass.max.azimuth, pass.max.elevation);
|
||
if (pMax.inFront) {
|
||
drawMilestoneBadge(pMax.x, pMax.y,
|
||
`⭐ 最高点 ${formatUserLocalTime(pass.max.time)}`,
|
||
`仰角 ${Math.round(pass.max.elevation)}° (${pass.max.azimuth_cardinal || ''})`,
|
||
'#ffd60a', true);
|
||
}
|
||
|
||
// Node 4: Mid-Descent (后半程)
|
||
const mid2Idx = Math.floor(pass.trajectory.length * 0.75);
|
||
const ptMid2 = pass.trajectory[mid2Idx];
|
||
if (ptMid2) {
|
||
const pMid2 = project(ptMid2.azimuth, ptMid2.elevation);
|
||
if (pMid2.inFront) {
|
||
drawMilestoneBadge(pMid2.x, pMid2.y,
|
||
`⏱️ ${formatUserLocalTime(ptMid2.time)}`,
|
||
`${Math.round(ptMid2.elevation)}°`,
|
||
'#5ac8fa', false);
|
||
}
|
||
}
|
||
|
||
// Node 5: End / 降落 (LOS)
|
||
const pEnd = project(pass.end.azimuth, pass.end.elevation);
|
||
if (pEnd.inFront) {
|
||
drawMilestoneBadge(pEnd.x, pEnd.y,
|
||
`🔴 降落 ${formatUserLocalTime(pass.end.time)}`,
|
||
`${Math.round(pass.end.elevation)}° (${pass.end.azimuth_cardinal || ''})`,
|
||
'#ff453a', true);
|
||
}
|
||
|
||
// 3. Position satellite at culmination (pass.max)
|
||
const satProj = project(pass.max.azimuth, pass.max.elevation);
|
||
if (satProj.inFront && satProj.x >= 30 && satProj.x <= canvas.width - 30 &&
|
||
satProj.y >= 30 && satProj.y <= canvas.height - 30) {
|
||
ctx.save();
|
||
ctx.strokeStyle = '#ffd60a';
|
||
ctx.lineWidth = 2.5;
|
||
ctx.beginPath();
|
||
ctx.arc(satProj.x, satProj.y, 20, 0, Math.PI * 2);
|
||
ctx.stroke();
|
||
|
||
ctx.font = '22px sans-serif';
|
||
ctx.textAlign = 'center';
|
||
ctx.textBaseline = 'middle';
|
||
ctx.fillText('🛰️', satProj.x, satProj.y);
|
||
|
||
const badgeText = `${satName} (最高点模拟)`;
|
||
ctx.font = 'bold 12px -apple-system, BlinkMacSystemFont, sans-serif';
|
||
const bWidth = ctx.measureText(badgeText).width + 16;
|
||
ctx.fillStyle = 'rgba(0, 122, 255, 0.88)';
|
||
drawRoundRect(ctx, satProj.x - bWidth / 2, satProj.y + 24, bWidth, 22, 6, true, false);
|
||
ctx.fillStyle = '#ffffff';
|
||
ctx.fillText(badgeText, satProj.x, satProj.y + 35);
|
||
ctx.restore();
|
||
}
|
||
}
|
||
}
|
||
// ==============================================
|
||
// MODE 2: Live Tracking (实时 AR 追踪)
|
||
// ==============================================
|
||
else {
|
||
// 1. Draw Past Track (-45m): Orange dashed line leading up to satellite
|
||
if (livePastTrack && livePastTrack.length > 1) {
|
||
drawTrackPolyline(livePastTrack, 'rgba(255, 159, 10, 0.35)', 5, true);
|
||
drawTrackPolyline(livePastTrack, '#ff9f0a', 2.5, true);
|
||
|
||
// Past Milestone
|
||
const pIdx = Math.floor(livePastTrack.length / 2);
|
||
const ptP = livePastTrack[pIdx];
|
||
if (ptP && ptP.azimuth !== undefined) {
|
||
const p = project(ptP.azimuth, ptP.elevation);
|
||
if (p.inFront && p.x >= 30 && p.x <= canvas.width - 30 && p.y >= 30 && p.y <= canvas.height - 30) {
|
||
const mins = Math.round((Date.now() / 1000 - ptP.timestamp) / 60);
|
||
const tStr = formatUserLocalTime(new Date(ptP.timestamp * 1000).toISOString());
|
||
drawMilestoneBadge(p.x, p.y, `-${mins}分 (${tStr})`, `过去轨道 ${Math.round(ptP.elevation)}°`, '#ff9f0a', false);
|
||
}
|
||
}
|
||
}
|
||
|
||
// 2. Draw Future Track (+90m): Cyan solid line emerging from satellite
|
||
if (liveFutureTrack && liveFutureTrack.length > 1) {
|
||
drawTrackPolyline(liveFutureTrack, 'rgba(0, 245, 212, 0.3)', 6, false);
|
||
drawTrackPolyline(liveFutureTrack, '#00f5d4', 2.5, false);
|
||
|
||
// Future Milestones
|
||
[30, 60, 90].forEach(idx => {
|
||
if (liveFutureTrack.length > idx) {
|
||
const ptF = liveFutureTrack[idx];
|
||
if (ptF && ptF.azimuth !== undefined) {
|
||
const p = project(ptF.azimuth, ptF.elevation);
|
||
if (p.inFront && p.x >= 30 && p.x <= canvas.width - 30 && p.y >= 30 && p.y <= canvas.height - 30) {
|
||
const mins = Math.round((ptF.timestamp - Date.now() / 1000) / 60);
|
||
const tStr = formatUserLocalTime(new Date(ptF.timestamp * 1000).toISOString());
|
||
drawMilestoneBadge(p.x, p.y, `+${mins}分 (${tStr})`, `预报轨道 ${Math.round(ptF.elevation)}°`, '#00f5d4', false);
|
||
}
|
||
}
|
||
}
|
||
});
|
||
}
|
||
|
||
// 3. Live Satellite Marker (locked exactly at currentTargetLook on canvas)
|
||
const satProj = project(currentTargetLook.azimuth, currentTargetLook.elevation);
|
||
const isInsideFOV = (satProj.inFront &&
|
||
satProj.x >= 35 && satProj.x <= canvas.width - 35 &&
|
||
satProj.y >= 35 && satProj.y <= canvas.height - 35);
|
||
const isAboveHorizon = currentTargetLook.elevation > 0;
|
||
|
||
if (isInsideFOV) {
|
||
arrowGuide.style.display = 'none';
|
||
|
||
ctx.save();
|
||
// Connecting laser reticle from center to satellite
|
||
ctx.beginPath();
|
||
ctx.strokeStyle = isAboveHorizon ? 'rgba(0, 245, 212, 0.7)' : 'rgba(255, 159, 10, 0.6)';
|
||
ctx.lineWidth = 1.5;
|
||
ctx.setLineDash([4, 4]);
|
||
ctx.moveTo(canvas.width / 2, canvas.height / 2);
|
||
ctx.lineTo(satProj.x, satProj.y);
|
||
ctx.stroke();
|
||
ctx.setLineDash([]);
|
||
|
||
// Outer pulsing target ring
|
||
ctx.strokeStyle = isAboveHorizon ? '#00f5d4' : 'rgba(255, 159, 10, 0.85)';
|
||
ctx.lineWidth = 2.5;
|
||
ctx.beginPath();
|
||
ctx.arc(satProj.x, satProj.y, 22, 0, Math.PI * 2);
|
||
ctx.stroke();
|
||
|
||
// Inner soft glowing circle
|
||
ctx.fillStyle = isAboveHorizon ? 'rgba(0, 245, 212, 0.22)' : 'rgba(255, 159, 10, 0.18)';
|
||
ctx.beginPath();
|
||
ctx.arc(satProj.x, satProj.y, 22, 0, Math.PI * 2);
|
||
ctx.fill();
|
||
|
||
// 🛰️ emoji centered exactly at target
|
||
ctx.font = '22px sans-serif';
|
||
ctx.textAlign = 'center';
|
||
ctx.textBaseline = 'middle';
|
||
ctx.fillText('🛰️', satProj.x, satProj.y);
|
||
|
||
// Badge under satellite
|
||
const badgeText = isAboveHorizon ? `${satName} (在视野内)` : `🛰️ ${satName} (地底 ${Math.round(currentTargetLook.elevation)}°)`;
|
||
ctx.font = 'bold 12px -apple-system, BlinkMacSystemFont, sans-serif';
|
||
const bWidth = ctx.measureText(badgeText).width + 16;
|
||
ctx.fillStyle = isAboveHorizon ? 'rgba(0, 122, 255, 0.88)' : 'rgba(255, 159, 10, 0.85)';
|
||
drawRoundRect(ctx, satProj.x - bWidth / 2, satProj.y + 26, bWidth, 22, 6, true, false);
|
||
ctx.fillStyle = '#ffffff';
|
||
ctx.fillText(badgeText, satProj.x, satProj.y + 37);
|
||
ctx.restore();
|
||
} else {
|
||
arrowGuide.style.display = 'flex';
|
||
|
||
// 3D Angle offset in degrees
|
||
const angleDeg = Math.round(Math.acos(Math.max(-1, Math.min(1, satProj.zc))) * (180 / Math.PI));
|
||
|
||
// Screen angle for rotation
|
||
const screenAngleRad = Math.atan2(-satProj.yc, satProj.xc);
|
||
const screenAngleDeg = screenAngleRad * (180 / Math.PI);
|
||
|
||
let dirHint = '';
|
||
if (satProj.xc > 0.15) dirHint += '👉 向右';
|
||
else if (satProj.xc < -0.15) dirHint += '👈 向左';
|
||
|
||
if (satProj.yc > 0.15) dirHint += (dirHint ? ' ' : '') + '👆 仰头';
|
||
else if (satProj.yc < -0.15) dirHint += (dirHint ? ' ' : '') + '👇 低头';
|
||
|
||
if (satProj.zc < 0) {
|
||
dirHint = '🔄 转身 ' + (dirHint || '向后');
|
||
}
|
||
|
||
if (!isAboveHorizon) {
|
||
arrowText.textContent = `${dirHint} 卫星在地底 (${angleDeg}°)`;
|
||
} else {
|
||
arrowText.textContent = `${dirHint} 寻找卫星 (${angleDeg}°)`;
|
||
}
|
||
arrowIcon.style.transform = `rotate(${Math.round(screenAngleDeg)}deg)`;
|
||
arrowIcon.textContent = '➔';
|
||
}
|
||
}
|
||
|
||
arAnimationId = requestAnimationFrame(renderFrame);
|
||
}
|
||
|
||
arAnimationId = requestAnimationFrame(renderFrame);
|
||
}
|
||
|
||
function updateARHUDData(data) {
|
||
if (data.observer_look) {
|
||
document.getElementById('ov-ar-target-look').textContent = `${data.observer_look.azimuth}° / ${data.observer_look.elevation}°`;
|
||
}
|
||
}
|
||
|
||
// Auto-init listeners
|
||
window.addEventListener('DOMContentLoaded', () => {
|
||
if (window.location.pathname.includes('orbit-viewer')) {
|
||
initOrbitViewer();
|
||
}
|
||
});
|
||
|
||
window.addEventListener('load', () => {
|
||
if (window.location.pathname.includes('orbit-viewer')) {
|
||
initOrbitViewer();
|
||
}
|
||
});
|
||
|
||
window.addEventListener('panel-changed', (e) => {
|
||
if (e.detail && e.detail.path === 'orbit-viewer') {
|
||
initOrbitViewer();
|
||
}
|
||
});
|
||
|
||
// Execute immediately in case script loaded after DOM
|
||
if (window.location.pathname.includes('orbit-viewer')) {
|
||
setTimeout(initOrbitViewer, 50);
|
||
}
|
||
</script>
|
||
{{end}}
|