Files
2026-09-23 00:06:01 -07:00

1939 lines
81 KiB
HTML
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
{{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}}