2650 lines
90 KiB
HTML
2650 lines
90 KiB
HTML
<!doctype html>
|
||
<html lang="zh-CN">
|
||
<head>
|
||
<meta charset="utf-8">
|
||
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||
<link rel="icon" href="data:,">
|
||
<title>八大行星-太阳系</title>
|
||
<style>
|
||
:root {
|
||
color-scheme: dark;
|
||
--bg: #03050a;
|
||
--panel: rgba(9, 12, 18, 0.88);
|
||
--panel-strong: rgba(13, 17, 24, 0.96);
|
||
--line: rgba(190, 211, 255, 0.22);
|
||
--text: #f4f7ff;
|
||
--muted: rgba(226, 235, 255, 0.68);
|
||
--accent: #7fb7ff;
|
||
--gold: #ffd36a;
|
||
font-family: "Microsoft YaHei UI", "Microsoft YaHei", "Segoe UI", sans-serif;
|
||
}
|
||
|
||
* {
|
||
box-sizing: border-box;
|
||
}
|
||
|
||
html,
|
||
body,
|
||
#app {
|
||
width: 100%;
|
||
height: 100%;
|
||
margin: 0;
|
||
overflow: hidden;
|
||
background: var(--bg);
|
||
color: var(--text);
|
||
}
|
||
|
||
#app {
|
||
position: relative;
|
||
min-height: 360px;
|
||
}
|
||
|
||
#stage {
|
||
position: absolute;
|
||
inset: 0;
|
||
overflow: hidden;
|
||
background: #03050a;
|
||
touch-action: none;
|
||
}
|
||
|
||
#stage canvas {
|
||
display: block;
|
||
width: 100%;
|
||
height: 100%;
|
||
outline: none;
|
||
}
|
||
|
||
.scene-title {
|
||
position: absolute;
|
||
z-index: 5;
|
||
top: 22px;
|
||
left: 24px;
|
||
max-width: min(460px, calc(100% - 48px));
|
||
pointer-events: none;
|
||
}
|
||
|
||
.scene-title h1 {
|
||
margin: 0;
|
||
font-size: 28px;
|
||
line-height: 1.15;
|
||
font-weight: 700;
|
||
letter-spacing: 0;
|
||
}
|
||
|
||
.scene-title p {
|
||
margin: 8px 0 0;
|
||
color: var(--muted);
|
||
font-size: 13px;
|
||
line-height: 1.65;
|
||
}
|
||
|
||
.status {
|
||
position: absolute;
|
||
z-index: 5;
|
||
left: 24px;
|
||
bottom: 22px;
|
||
display: grid;
|
||
gap: 8px;
|
||
width: min(390px, calc(100% - 48px));
|
||
padding: 12px 13px;
|
||
border: 1px solid var(--line);
|
||
border-radius: 8px;
|
||
background: rgba(9, 12, 18, 0.78);
|
||
color: var(--muted);
|
||
font-size: 12px;
|
||
line-height: 1.45;
|
||
backdrop-filter: blur(14px);
|
||
pointer-events: none;
|
||
box-shadow: 0 18px 44px rgba(0, 0, 0, 0.28);
|
||
}
|
||
|
||
.status-main {
|
||
display: grid;
|
||
grid-template-columns: auto minmax(0, 1fr) auto;
|
||
align-items: center;
|
||
gap: 9px;
|
||
}
|
||
|
||
.status-dot {
|
||
width: 8px;
|
||
height: 8px;
|
||
flex: 0 0 auto;
|
||
border-radius: 50%;
|
||
background: var(--accent);
|
||
box-shadow: 0 0 18px rgba(127, 183, 255, 0.8);
|
||
}
|
||
|
||
.status-copy {
|
||
min-width: 0;
|
||
}
|
||
|
||
.status-copy strong,
|
||
.status-copy span {
|
||
display: block;
|
||
overflow: hidden;
|
||
text-overflow: ellipsis;
|
||
white-space: nowrap;
|
||
}
|
||
|
||
.status-copy strong {
|
||
color: var(--text);
|
||
font-size: 12px;
|
||
font-weight: 700;
|
||
}
|
||
|
||
.status-copy span {
|
||
margin-top: 1px;
|
||
color: var(--muted);
|
||
font-size: 11px;
|
||
}
|
||
|
||
.status-percent {
|
||
color: var(--gold);
|
||
font-size: 12px;
|
||
font-variant-numeric: tabular-nums;
|
||
font-weight: 700;
|
||
}
|
||
|
||
.status-bar {
|
||
height: 5px;
|
||
overflow: hidden;
|
||
border-radius: 999px;
|
||
background: rgba(255, 255, 255, 0.11);
|
||
}
|
||
|
||
.status-fill {
|
||
width: 0%;
|
||
height: 100%;
|
||
border-radius: inherit;
|
||
background: linear-gradient(90deg, var(--accent), var(--gold));
|
||
transition: width 140ms linear;
|
||
}
|
||
|
||
.status-meta {
|
||
color: rgba(226, 235, 255, 0.56);
|
||
font-size: 11px;
|
||
line-height: 1.45;
|
||
}
|
||
|
||
.planet-labels {
|
||
position: absolute;
|
||
inset: 0;
|
||
z-index: 6;
|
||
pointer-events: none;
|
||
}
|
||
|
||
.planet-label {
|
||
position: absolute;
|
||
display: grid;
|
||
gap: 1px;
|
||
min-width: 58px;
|
||
padding: 5px 7px;
|
||
border: 1px solid rgba(190, 211, 255, 0.28);
|
||
border-radius: 8px;
|
||
background: rgba(7, 10, 16, 0.58);
|
||
box-shadow: 0 10px 26px rgba(0, 0, 0, 0.24);
|
||
color: var(--text);
|
||
transform: translate(-50%, -50%);
|
||
backdrop-filter: blur(10px);
|
||
opacity: 0;
|
||
transition: opacity 140ms ease, transform 140ms ease;
|
||
}
|
||
|
||
.planet-label.is-visible {
|
||
opacity: 1;
|
||
}
|
||
|
||
.planet-label strong {
|
||
display: block;
|
||
overflow: hidden;
|
||
text-overflow: ellipsis;
|
||
white-space: nowrap;
|
||
font-size: 11px;
|
||
line-height: 1.25;
|
||
font-weight: 700;
|
||
}
|
||
|
||
.planet-label span {
|
||
display: block;
|
||
overflow: hidden;
|
||
text-overflow: ellipsis;
|
||
white-space: nowrap;
|
||
color: rgba(226, 235, 255, 0.66);
|
||
font-size: 9px;
|
||
line-height: 1.2;
|
||
}
|
||
|
||
.hint {
|
||
position: absolute;
|
||
z-index: 5;
|
||
right: 22px;
|
||
bottom: 22px;
|
||
display: flex;
|
||
align-items: center;
|
||
gap: 10px;
|
||
max-width: min(320px, calc(100% - 44px));
|
||
padding: 12px 14px;
|
||
border: 1px solid var(--line);
|
||
border-radius: 8px;
|
||
background: rgba(9, 12, 18, 0.72);
|
||
color: var(--text);
|
||
backdrop-filter: blur(16px);
|
||
box-shadow: 0 18px 44px rgba(0, 0, 0, 0.34);
|
||
transition: opacity 180ms ease, transform 180ms ease;
|
||
pointer-events: none;
|
||
}
|
||
|
||
.hint.is-hidden {
|
||
opacity: 0;
|
||
transform: translateY(10px);
|
||
}
|
||
|
||
.planet-tooltip {
|
||
position: absolute;
|
||
z-index: 7;
|
||
min-width: 104px;
|
||
max-width: 220px;
|
||
padding: 8px 10px;
|
||
border: 1px solid rgba(190, 211, 255, 0.24);
|
||
border-radius: 8px;
|
||
background: rgba(9, 12, 18, 0.84);
|
||
color: var(--text);
|
||
box-shadow: 0 16px 38px rgba(0, 0, 0, 0.34);
|
||
backdrop-filter: blur(14px);
|
||
opacity: 0;
|
||
visibility: hidden;
|
||
transform: translate(10px, 10px);
|
||
pointer-events: none;
|
||
transition: opacity 120ms ease, visibility 120ms ease;
|
||
}
|
||
|
||
.planet-tooltip.is-visible {
|
||
opacity: 1;
|
||
visibility: visible;
|
||
}
|
||
|
||
.planet-tooltip strong,
|
||
.planet-tooltip span {
|
||
display: block;
|
||
overflow: hidden;
|
||
text-overflow: ellipsis;
|
||
white-space: nowrap;
|
||
}
|
||
|
||
.planet-tooltip strong {
|
||
font-size: 13px;
|
||
line-height: 1.35;
|
||
font-weight: 700;
|
||
}
|
||
|
||
.planet-tooltip span {
|
||
margin-top: 2px;
|
||
color: var(--muted);
|
||
font-size: 11px;
|
||
line-height: 1.35;
|
||
}
|
||
|
||
.hint-mark {
|
||
width: 34px;
|
||
height: 34px;
|
||
flex: 0 0 auto;
|
||
border-radius: 50%;
|
||
border: 1px solid rgba(127, 183, 255, 0.56);
|
||
background: rgba(127, 183, 255, 0.13);
|
||
display: grid;
|
||
place-items: center;
|
||
color: var(--accent);
|
||
font-size: 18px;
|
||
}
|
||
|
||
.hint strong {
|
||
display: block;
|
||
font-size: 14px;
|
||
line-height: 1.4;
|
||
font-weight: 700;
|
||
}
|
||
|
||
.hint span {
|
||
display: block;
|
||
margin-top: 2px;
|
||
color: var(--muted);
|
||
font-size: 12px;
|
||
line-height: 1.45;
|
||
}
|
||
|
||
.info-panel {
|
||
position: absolute;
|
||
z-index: 8;
|
||
top: 22px;
|
||
right: 22px;
|
||
width: min(410px, calc(100% - 44px));
|
||
max-height: calc(100% - 44px);
|
||
display: flex;
|
||
flex-direction: column;
|
||
border: 1px solid var(--line);
|
||
border-radius: 8px;
|
||
background: var(--panel-strong);
|
||
box-shadow: 0 24px 58px rgba(0, 0, 0, 0.5);
|
||
backdrop-filter: blur(18px);
|
||
opacity: 0;
|
||
visibility: hidden;
|
||
transform: translateY(10px);
|
||
pointer-events: none;
|
||
transition: opacity 180ms ease, visibility 180ms ease, transform 180ms ease;
|
||
}
|
||
|
||
.info-panel.is-visible {
|
||
opacity: 1;
|
||
visibility: visible;
|
||
transform: translateY(0);
|
||
pointer-events: auto;
|
||
}
|
||
|
||
.info-head {
|
||
position: relative;
|
||
padding: 18px 54px 12px 18px;
|
||
border-bottom: 1px solid rgba(190, 211, 255, 0.16);
|
||
}
|
||
|
||
.info-head h2 {
|
||
margin: 0;
|
||
font-size: 23px;
|
||
line-height: 1.2;
|
||
font-weight: 700;
|
||
letter-spacing: 0;
|
||
}
|
||
|
||
.kind {
|
||
margin-top: 8px;
|
||
color: var(--gold);
|
||
font-size: 13px;
|
||
line-height: 1.5;
|
||
}
|
||
|
||
.close,
|
||
.back-button {
|
||
border: 1px solid rgba(127, 183, 255, 0.38);
|
||
border-radius: 8px;
|
||
background: rgba(127, 183, 255, 0.12);
|
||
color: var(--text);
|
||
cursor: pointer;
|
||
}
|
||
|
||
.close {
|
||
position: absolute;
|
||
top: 14px;
|
||
right: 14px;
|
||
width: 32px;
|
||
height: 32px;
|
||
font-size: 20px;
|
||
line-height: 30px;
|
||
text-align: center;
|
||
}
|
||
|
||
.close:hover,
|
||
.close:focus-visible,
|
||
.back-button:hover,
|
||
.back-button:focus-visible {
|
||
border-color: rgba(127, 183, 255, 0.76);
|
||
background: rgba(127, 183, 255, 0.22);
|
||
outline: none;
|
||
}
|
||
|
||
.info-body {
|
||
overflow: auto;
|
||
padding: 16px 18px 18px;
|
||
}
|
||
|
||
.meta-grid {
|
||
display: grid;
|
||
gap: 8px;
|
||
margin-bottom: 14px;
|
||
}
|
||
|
||
.meta-row {
|
||
display: grid;
|
||
grid-template-columns: 86px 1fr;
|
||
gap: 10px;
|
||
color: var(--muted);
|
||
font-size: 12px;
|
||
line-height: 1.55;
|
||
}
|
||
|
||
.meta-row b {
|
||
color: rgba(244, 247, 255, 0.92);
|
||
font-weight: 600;
|
||
}
|
||
|
||
.summary {
|
||
margin: 0;
|
||
color: rgba(244, 247, 255, 0.9);
|
||
font-size: 13px;
|
||
line-height: 1.75;
|
||
}
|
||
|
||
.detail-stack {
|
||
display: grid;
|
||
gap: 10px;
|
||
margin-top: 14px;
|
||
}
|
||
|
||
.detail-block {
|
||
display: grid;
|
||
gap: 6px;
|
||
padding-top: 10px;
|
||
border-top: 1px solid rgba(190, 211, 255, 0.14);
|
||
}
|
||
|
||
.detail-block b {
|
||
color: rgba(244, 247, 255, 0.92);
|
||
font-size: 12px;
|
||
line-height: 1.4;
|
||
font-weight: 700;
|
||
}
|
||
|
||
.detail-block span,
|
||
.detail-block li {
|
||
color: var(--muted);
|
||
font-size: 12px;
|
||
line-height: 1.6;
|
||
}
|
||
|
||
.detail-block ul {
|
||
display: grid;
|
||
gap: 5px;
|
||
margin: 0;
|
||
padding-left: 16px;
|
||
}
|
||
|
||
.facts,
|
||
.sources {
|
||
display: grid;
|
||
gap: 8px;
|
||
margin: 14px 0 0;
|
||
padding: 0;
|
||
list-style: none;
|
||
}
|
||
|
||
.facts li,
|
||
.sources li {
|
||
position: relative;
|
||
padding-left: 14px;
|
||
color: var(--muted);
|
||
font-size: 12px;
|
||
line-height: 1.6;
|
||
}
|
||
|
||
.facts li::before,
|
||
.sources li::before {
|
||
content: "";
|
||
position: absolute;
|
||
top: 0.72em;
|
||
left: 0;
|
||
width: 5px;
|
||
height: 5px;
|
||
border-radius: 50%;
|
||
background: var(--accent);
|
||
}
|
||
|
||
.earth-frame {
|
||
position: absolute;
|
||
inset: 0;
|
||
z-index: 12;
|
||
display: none;
|
||
background: #05070a;
|
||
}
|
||
|
||
.earth-frame.is-visible {
|
||
display: block;
|
||
}
|
||
|
||
.earth-frame iframe {
|
||
width: 100%;
|
||
height: 100%;
|
||
border: 0;
|
||
background: #05070a;
|
||
}
|
||
|
||
.back-button {
|
||
position: absolute;
|
||
z-index: 14;
|
||
top: 16px;
|
||
right: 16px;
|
||
display: none;
|
||
height: 38px;
|
||
padding: 0 14px;
|
||
align-items: center;
|
||
gap: 8px;
|
||
font-size: 13px;
|
||
line-height: 38px;
|
||
backdrop-filter: blur(14px);
|
||
}
|
||
|
||
.back-button.is-visible {
|
||
display: inline-flex;
|
||
}
|
||
|
||
.loading {
|
||
position: absolute;
|
||
inset: 0;
|
||
z-index: 20;
|
||
display: grid;
|
||
place-items: center;
|
||
padding: 24px;
|
||
background: radial-gradient(circle at 50% 45%, rgba(42, 69, 110, 0.24), rgba(3, 5, 10, 0.94) 62%, #03050a 100%);
|
||
transition: opacity 240ms ease, visibility 240ms ease;
|
||
}
|
||
|
||
.loading.is-hidden {
|
||
opacity: 0;
|
||
visibility: hidden;
|
||
display: none;
|
||
pointer-events: none;
|
||
}
|
||
|
||
.loader-card {
|
||
width: min(420px, 100%);
|
||
border: 1px solid var(--line);
|
||
border-radius: 8px;
|
||
background: var(--panel);
|
||
padding: 18px;
|
||
box-shadow: 0 24px 60px rgba(0, 0, 0, 0.48);
|
||
backdrop-filter: blur(18px);
|
||
}
|
||
|
||
.loader-title {
|
||
display: flex;
|
||
justify-content: space-between;
|
||
gap: 12px;
|
||
font-size: 14px;
|
||
line-height: 1.4;
|
||
font-weight: 700;
|
||
}
|
||
|
||
.loader-title span:last-child {
|
||
color: var(--gold);
|
||
font-variant-numeric: tabular-nums;
|
||
}
|
||
|
||
.bar {
|
||
height: 8px;
|
||
margin-top: 14px;
|
||
overflow: hidden;
|
||
border-radius: 999px;
|
||
background: rgba(255, 255, 255, 0.12);
|
||
}
|
||
|
||
.bar-fill {
|
||
width: 0%;
|
||
height: 100%;
|
||
border-radius: inherit;
|
||
background: linear-gradient(90deg, var(--accent), var(--gold));
|
||
transition: width 120ms linear;
|
||
}
|
||
|
||
.loader-note {
|
||
margin-top: 10px;
|
||
color: var(--muted);
|
||
font-size: 12px;
|
||
line-height: 1.5;
|
||
}
|
||
|
||
@media (max-width: 760px) {
|
||
.scene-title {
|
||
top: 18px;
|
||
left: 18px;
|
||
max-width: calc(100% - 36px);
|
||
}
|
||
|
||
.scene-title h1 {
|
||
font-size: 22px;
|
||
}
|
||
|
||
.scene-title p {
|
||
max-width: 330px;
|
||
font-size: 12px;
|
||
}
|
||
|
||
.status {
|
||
left: 18px;
|
||
right: 18px;
|
||
bottom: 86px;
|
||
width: auto;
|
||
max-width: none;
|
||
}
|
||
|
||
.hint {
|
||
left: 18px;
|
||
right: 18px;
|
||
bottom: 18px;
|
||
max-width: none;
|
||
}
|
||
|
||
.info-panel {
|
||
top: auto;
|
||
right: 12px;
|
||
bottom: 12px;
|
||
left: 12px;
|
||
width: auto;
|
||
max-height: min(60%, 460px);
|
||
}
|
||
|
||
.planet-label {
|
||
min-width: 48px;
|
||
padding: 4px 6px;
|
||
}
|
||
}
|
||
</style>
|
||
<script type="importmap">
|
||
{
|
||
"imports": {
|
||
"three": "./vendor/three.module.js"
|
||
}
|
||
}
|
||
</script>
|
||
</head>
|
||
<body>
|
||
<div id="app">
|
||
<div id="stage" aria-label="八大行星-太阳系 3D 场景"></div>
|
||
<section class="scene-title" aria-label="主题">
|
||
<h1>八大行星-太阳系</h1>
|
||
<p>根据当前系统时间推算行星轨道位置。点击行星查看权威说明,点击地球进入世界文明古国。</p>
|
||
</section>
|
||
<div class="status" aria-live="polite">
|
||
<div class="status-main">
|
||
<span class="status-dot"></span>
|
||
<span class="status-copy">
|
||
<strong id="syncStatus">初始化太阳系</strong>
|
||
<span id="asyncStatus">准备本地模型、星云、粒子和星历同步。</span>
|
||
</span>
|
||
<span id="asyncPercent" class="status-percent">0%</span>
|
||
</div>
|
||
<div class="status-bar"><div id="asyncBar" class="status-fill"></div></div>
|
||
<div id="asyncMeta" class="status-meta">本地 Three.js 资源加载中</div>
|
||
</div>
|
||
<div id="hint" class="hint">
|
||
<div class="hint-mark">◎</div>
|
||
<div>
|
||
<strong>选择太阳或行星</strong>
|
||
<span>点击地球进入世界文明古国界面</span>
|
||
</div>
|
||
</div>
|
||
<div id="planetTooltip" class="planet-tooltip" aria-hidden="true">
|
||
<strong id="tooltipName"></strong>
|
||
<span id="tooltipKind"></span>
|
||
</div>
|
||
<div id="planetLabels" class="planet-labels" aria-hidden="true"></div>
|
||
<article id="infoPanel" class="info-panel" aria-hidden="true">
|
||
<header class="info-head">
|
||
<button id="closePanel" class="close" type="button" aria-label="关闭">×</button>
|
||
<h2 id="bodyName"></h2>
|
||
<div id="bodyKind" class="kind"></div>
|
||
</header>
|
||
<div class="info-body">
|
||
<div class="meta-grid">
|
||
<div class="meta-row"><b>直径</b><span id="bodyDiameter"></span></div>
|
||
<div class="meta-row"><b>太阳距离</b><span id="bodyDistance"></span></div>
|
||
<div class="meta-row"><b>公转周期</b><span id="bodyOrbit"></span></div>
|
||
<div class="meta-row"><b>自转周期</b><span id="bodyRotation"></span></div>
|
||
<div class="meta-row"><b>卫星数量</b><span id="bodyMoons"></span></div>
|
||
</div>
|
||
<p id="bodySummary" class="summary"></p>
|
||
<div class="detail-stack">
|
||
<div class="detail-block"><b>行星组与构成</b><span id="bodyComposition"></span></div>
|
||
<div class="detail-block"><b>观测亮点</b><ul id="bodyHighlights"></ul></div>
|
||
<div class="detail-block"><b>代表任务</b><span id="bodyMissions"></span></div>
|
||
<div class="detail-block"><b>星环 / 视觉说明</b><span id="bodyVisualNotes"></span></div>
|
||
</div>
|
||
<ul id="bodyFacts" class="facts"></ul>
|
||
<ul id="bodySources" class="sources"></ul>
|
||
</div>
|
||
</article>
|
||
<div id="earthFrame" class="earth-frame" aria-hidden="true">
|
||
<iframe id="earthIframe" title="世界文明古国"></iframe>
|
||
</div>
|
||
<button id="backButton" class="back-button" type="button">← 返回行星组</button>
|
||
<div id="loading" class="loading" role="status" aria-live="polite">
|
||
<div class="loader-card">
|
||
<div class="loader-title">
|
||
<span id="loadLabel">初始化太阳系场景</span>
|
||
<span id="loadPercent">0%</span>
|
||
</div>
|
||
<div class="bar"><div id="loadBar" class="bar-fill"></div></div>
|
||
<div id="loadNote" class="loader-note">正在读取本地 Three.js 模块、生成星系背景与计算行星轨道。</div>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
|
||
<script type="module">
|
||
import * as THREE from 'three';
|
||
import { OrbitControls } from './vendor/OrbitControls.js';
|
||
import { SOLAR_SYSTEM_BODIES, PLANETS } from './solar-system-data.js';
|
||
import { approximateSolarSystem, orbitSamples, orbitalPeriodDays } from './ephemeris.js';
|
||
|
||
const stage = document.getElementById('stage');
|
||
const loading = document.getElementById('loading');
|
||
const loadLabel = document.getElementById('loadLabel');
|
||
const loadPercent = document.getElementById('loadPercent');
|
||
const loadBar = document.getElementById('loadBar');
|
||
const loadNote = document.getElementById('loadNote');
|
||
const syncStatus = document.getElementById('syncStatus');
|
||
const asyncStatus = document.getElementById('asyncStatus');
|
||
const asyncPercent = document.getElementById('asyncPercent');
|
||
const asyncBar = document.getElementById('asyncBar');
|
||
const asyncMeta = document.getElementById('asyncMeta');
|
||
const hint = document.getElementById('hint');
|
||
const planetTooltip = document.getElementById('planetTooltip');
|
||
const planetLabels = document.getElementById('planetLabels');
|
||
const tooltipName = document.getElementById('tooltipName');
|
||
const tooltipKind = document.getElementById('tooltipKind');
|
||
const infoPanel = document.getElementById('infoPanel');
|
||
const closePanel = document.getElementById('closePanel');
|
||
const earthFrame = document.getElementById('earthFrame');
|
||
const earthIframe = document.getElementById('earthIframe');
|
||
const backButton = document.getElementById('backButton');
|
||
const bodyName = document.getElementById('bodyName');
|
||
const bodyKind = document.getElementById('bodyKind');
|
||
const bodyDiameter = document.getElementById('bodyDiameter');
|
||
const bodyDistance = document.getElementById('bodyDistance');
|
||
const bodyOrbit = document.getElementById('bodyOrbit');
|
||
const bodyRotation = document.getElementById('bodyRotation');
|
||
const bodyMoons = document.getElementById('bodyMoons');
|
||
const bodySummary = document.getElementById('bodySummary');
|
||
const bodyComposition = document.getElementById('bodyComposition');
|
||
const bodyHighlights = document.getElementById('bodyHighlights');
|
||
const bodyMissions = document.getElementById('bodyMissions');
|
||
const bodyVisualNotes = document.getElementById('bodyVisualNotes');
|
||
const bodyFacts = document.getElementById('bodyFacts');
|
||
const bodySources = document.getElementById('bodySources');
|
||
|
||
const scene = new THREE.Scene();
|
||
const camera = new THREE.PerspectiveCamera(46, 1, 0.1, 2000);
|
||
const renderer = new THREE.WebGLRenderer({ antialias: true, alpha: false, powerPreference: 'high-performance' });
|
||
const controls = new OrbitControls(camera, renderer.domElement);
|
||
const raycaster = new THREE.Raycaster();
|
||
const pointer = new THREE.Vector2();
|
||
const clock = new THREE.Clock();
|
||
const bodyMeshes = new Map();
|
||
const orbitLines = new Map();
|
||
const orbitPathPoints = new Map();
|
||
const labelElements = new Map();
|
||
const selectable = [];
|
||
const textureLoader = new THREE.TextureLoader();
|
||
const remoteTargets = new Map();
|
||
const localPositions = new Map();
|
||
const effectObjects = [];
|
||
const reusableWorldPosition = new THREE.Vector3();
|
||
const reusableFocusTarget = new THREE.Vector3();
|
||
const defaultTarget = new THREE.Vector3(0, 0, 0);
|
||
const responsive = { scale: 1, cameraDistance: 74, y: 7 };
|
||
const visualState = detectVisualState();
|
||
let hostLightMode = false;
|
||
const renderStats = { elapsed: 0, frames: 0, qualityReduced: false };
|
||
const asyncTasks = new Map([
|
||
['three', { label: 'Three.js 模块', progress: 0 }],
|
||
['scene', { label: '本地高精度模型', progress: 0 }],
|
||
['textures', { label: '行星程序贴图', progress: 0 }],
|
||
['background', { label: '星云背景', progress: 0 }],
|
||
['ephemeris', { label: '星历同步', progress: 10 }],
|
||
['effects', { label: '粒子和星环特效', progress: 0 }],
|
||
['quality', { label: '渲染自适应', progress: 0 }]
|
||
]);
|
||
|
||
let activeObject = null;
|
||
let hoveredObject = null;
|
||
let progressValue = 0;
|
||
let userInteracted = false;
|
||
let detailLoadedId = null;
|
||
let focusTween = null;
|
||
let cameraTransition = null;
|
||
let returnTourTimer = null;
|
||
let lastLocalPositionUpdate = 0;
|
||
const hasHostBridge = Boolean(window.chrome?.webview);
|
||
const ephemerisState = {
|
||
requestId: null,
|
||
terminal: false,
|
||
status: 'pending',
|
||
message: '正在校验精确星历源',
|
||
source: '',
|
||
phase: 'initial'
|
||
};
|
||
|
||
const SELF_ROTATION_PERIOD_HOURS = {
|
||
mercury: 1407.6,
|
||
venus: -5832.5,
|
||
earth: 23.934,
|
||
mars: 24.623,
|
||
jupiter: 9.925,
|
||
saturn: 10.7,
|
||
uranus: -17.24,
|
||
neptune: 16.11
|
||
};
|
||
|
||
renderer.outputColorSpace = THREE.SRGBColorSpace;
|
||
renderer.toneMapping = THREE.ACESFilmicToneMapping;
|
||
renderer.toneMappingExposure = 1.08;
|
||
renderer.setClearColor(0x03050a, 1);
|
||
stage.appendChild(renderer.domElement);
|
||
|
||
controls.enableDamping = true;
|
||
controls.dampingFactor = 0.055;
|
||
controls.enablePan = false;
|
||
controls.minDistance = 22;
|
||
controls.maxDistance = 140;
|
||
controls.target.set(0, 0, 0);
|
||
|
||
function detectVisualState() {
|
||
const cores = navigator.hardwareConcurrency || 4;
|
||
const memory = navigator.deviceMemory || 8;
|
||
const highDpi = window.devicePixelRatio > 1.25;
|
||
const reducedMotion = window.matchMedia?.('(prefers-reduced-motion: reduce)').matches ?? false;
|
||
const highConfig = cores >= 8 && memory >= 8;
|
||
return {
|
||
animationsEnabled: !reducedMotion,
|
||
hardwareAccelerationEnabled: true,
|
||
highConfig,
|
||
particlesEnabled: highConfig && !reducedMotion,
|
||
textureScale: highConfig || highDpi ? 1 : 0.75,
|
||
textureResolution: highConfig || highDpi ? 4096 : 2048,
|
||
pixelRatioCap: highConfig ? 3 : 2
|
||
};
|
||
}
|
||
|
||
const resizeObserver = new ResizeObserver(resize);
|
||
resizeObserver.observe(stage);
|
||
renderer.domElement.addEventListener('click', handleClick);
|
||
renderer.domElement.addEventListener('pointermove', handlePointerMove);
|
||
renderer.domElement.addEventListener('pointerdown', cancelFocusTween);
|
||
renderer.domElement.addEventListener('wheel', cancelFocusTween, { passive: true });
|
||
closePanel.addEventListener('click', clearSelection);
|
||
backButton.addEventListener('click', showSolarSystem);
|
||
window.addEventListener('keydown', event => {
|
||
if (event.key === 'Escape') {
|
||
if (earthFrame.classList.contains('is-visible')) {
|
||
showSolarSystem();
|
||
} else {
|
||
clearSelection();
|
||
}
|
||
}
|
||
});
|
||
window.chrome?.webview?.addEventListener('message', event => applyHostMessage(event.data));
|
||
window.chrome?.webview?.postMessage?.({ type: 'ephemeris:ready' });
|
||
|
||
setAsyncProgress('three', 100, 'Three.js 模块已从本地加载');
|
||
setProgress(8, '初始化太阳系场景', '正在读取本地 Three.js 模块。');
|
||
syncStatus.textContent = '本地近似轨道计算中,精确星历同步已启动。';
|
||
buildScene();
|
||
setAsyncProgress('scene', 100, '太阳、八大行星、真实椭圆轨道已建立');
|
||
setProgress(58, '计算真实时间轨道', '正在根据当前系统时间推算八大行星日心位置。');
|
||
updatePlanetPositions(new Date());
|
||
setAsyncProgress('ephemeris', 35, '本地近似星历已完成,正在等待精确源校验');
|
||
loadGalaxyBackground();
|
||
setProgress(82, '生成星系背景', '正在生成本地星云背景和星点粒子。');
|
||
resize();
|
||
requestAnimationFrame(animate);
|
||
setProgress(100, '太阳系已就绪', '本地模型、轨道和交互已经加载完成。');
|
||
window.setTimeout(() => {
|
||
loading.classList.add('is-hidden');
|
||
setAsyncProgress('quality', 100, '渲染自适应已完成,高 DPI 模式就绪');
|
||
}, 260);
|
||
window.setTimeout(() => {
|
||
if (!hasHostBridge && asyncTasks.get('ephemeris')?.progress < 100) {
|
||
ephemerisState.terminal = true;
|
||
ephemerisState.status = 'local';
|
||
ephemerisState.message = '无宿主星历源,本地近似轨道已完成';
|
||
syncStatus.textContent = ephemerisState.message;
|
||
setAsyncProgress('ephemeris', 100, ephemerisState.message);
|
||
}
|
||
}, 1200);
|
||
|
||
function setProgress(value, label, note) {
|
||
progressValue = Math.max(progressValue, Math.min(100, Math.round(value)));
|
||
loadLabel.textContent = label;
|
||
loadNote.textContent = note;
|
||
loadPercent.textContent = `${progressValue}%`;
|
||
loadBar.style.width = `${progressValue}%`;
|
||
}
|
||
|
||
function setAsyncProgress(id, value, message) {
|
||
const task = asyncTasks.get(id);
|
||
if (!task) {
|
||
return;
|
||
}
|
||
|
||
task.progress = Math.max(task.progress, Math.min(100, Math.round(value)));
|
||
if (message) {
|
||
task.message = message;
|
||
}
|
||
const tasks = [...asyncTasks.values()];
|
||
const average = Math.round(tasks.reduce((sum, item) => sum + item.progress, 0) / tasks.length);
|
||
const active = tasks.find(item => item.progress < 100) ?? tasks.at(-1);
|
||
const displayPercent = ephemerisState.terminal ? 100 : average;
|
||
asyncStatus.textContent = ephemerisState.terminal
|
||
? ephemerisState.message
|
||
: (active?.message ?? active?.label ?? '场景已就绪');
|
||
asyncPercent.textContent = `${displayPercent}%`;
|
||
asyncBar.style.width = `${displayPercent}%`;
|
||
asyncMeta.textContent = tasks.map(item => `${item.label} ${item.progress}%`).join(' · ');
|
||
}
|
||
|
||
function buildScene() {
|
||
scene.background = new THREE.Color(0x03050a);
|
||
scene.add(new THREE.AmbientLight(0x1d2637, 0.24));
|
||
|
||
const sunLight = new THREE.PointLight(0xffe3a8, 18.5, 420, 1.08);
|
||
sunLight.position.set(0, 0, 0);
|
||
scene.add(sunLight);
|
||
|
||
const fillLight = new THREE.DirectionalLight(0xa8c6ff, 0.2);
|
||
fillLight.position.set(-72, 42, 78);
|
||
scene.add(fillLight);
|
||
|
||
const rimLight = new THREE.DirectionalLight(0x5f8fff, 0.28);
|
||
rimLight.position.set(52, 22, -64);
|
||
scene.add(rimLight);
|
||
|
||
const northLight = new THREE.HemisphereLight(0xb8d7ff, 0x05070a, 0.14);
|
||
scene.add(northLight);
|
||
|
||
createSpaceBackdrop();
|
||
createStarField();
|
||
createSolarWindParticles();
|
||
createKuiperDustBelt();
|
||
setAsyncProgress('effects', 100, visualState.particlesEnabled ? '高配置粒子特效已开启' : '基础粒子特效已按配置收敛');
|
||
|
||
for (const body of SOLAR_SYSTEM_BODIES) {
|
||
if (body.id !== 'sun') {
|
||
const orbit = createOrbitLine(body, body.color);
|
||
orbitLines.set(body.id, orbit);
|
||
scene.add(orbit);
|
||
scene.add(createOrbitTracer(body));
|
||
}
|
||
|
||
const bodyObject = createBodyMesh(body);
|
||
bodyMeshes.set(body.id, bodyObject);
|
||
selectable.push(bodyObject.planetMesh);
|
||
scene.add(bodyObject.pivot);
|
||
createPlanetLabel(body);
|
||
setAsyncProgress('textures', Math.min(100, (bodyMeshes.size / SOLAR_SYSTEM_BODIES.length) * 100), `正在载入 ${body.name} 高清纹理`);
|
||
}
|
||
}
|
||
|
||
function loadGalaxyBackground() {
|
||
textureLoader.load(
|
||
'./galaxy-panorama.jpg',
|
||
texture => {
|
||
texture.colorSpace = THREE.SRGBColorSpace;
|
||
texture.wrapS = THREE.ClampToEdgeWrapping;
|
||
texture.wrapT = THREE.ClampToEdgeWrapping;
|
||
applyGalaxyTextureToNebula(texture);
|
||
setAsyncProgress('background', 100, '本地 NASA 星云图已作为远景模型载入');
|
||
setProgress(88, '读取本地星系云', '本地 NASA 星系云作为局部远景模型载入,星点粒子继续叠加渲染。');
|
||
},
|
||
event => {
|
||
if (event.lengthComputable && event.total > 0) {
|
||
setAsyncProgress('background', 42 + (event.loaded / event.total) * 48, '正在读取本地星云图片');
|
||
setProgress(82 + (event.loaded / event.total) * 6, '读取本地星系云', '正在从随包资源读取星系云背景。');
|
||
}
|
||
},
|
||
() => {
|
||
setAsyncProgress('background', 100, '本地星云图读取失败,已使用程序星云背景');
|
||
setProgress(88, '使用程序星云背景', '未读取到本地星系图,已使用程序生成星云背景。');
|
||
});
|
||
}
|
||
|
||
function createSpaceBackdrop() {
|
||
const shellGeometry = new THREE.SphereGeometry(620, 192, 96);
|
||
const shellMaterial = new THREE.MeshBasicMaterial({
|
||
map: createSeamlessSpaceTexture(),
|
||
side: THREE.BackSide,
|
||
depthWrite: false,
|
||
fog: false
|
||
});
|
||
const shell = new THREE.Mesh(shellGeometry, shellMaterial);
|
||
shell.name = 'space-backdrop-shell';
|
||
scene.add(shell);
|
||
|
||
const nebulaGroup = new THREE.Group();
|
||
nebulaGroup.name = 'space-nebula-model';
|
||
const palette = [0x446fbd, 0x6f5ba6, 0x2d8fbe, 0xc79d62];
|
||
for (let index = 0; index < 18; index += 1) {
|
||
const plane = new THREE.Mesh(
|
||
new THREE.PlaneGeometry(120 + Math.random() * 170, 42 + Math.random() * 76, 1, 1),
|
||
new THREE.MeshBasicMaterial({
|
||
color: palette[index % palette.length],
|
||
transparent: true,
|
||
opacity: 0.035 + Math.random() * 0.038,
|
||
blending: THREE.AdditiveBlending,
|
||
depthWrite: false,
|
||
side: THREE.DoubleSide
|
||
})
|
||
);
|
||
const radius = 330 + Math.random() * 210;
|
||
const theta = Math.random() * Math.PI * 2;
|
||
const y = -120 + Math.random() * 240;
|
||
plane.position.set(Math.cos(theta) * radius, y, Math.sin(theta) * radius);
|
||
plane.lookAt(0, 0, 0);
|
||
plane.rotateZ((Math.random() - 0.5) * 0.9);
|
||
nebulaGroup.add(plane);
|
||
}
|
||
scene.add(nebulaGroup);
|
||
}
|
||
|
||
function applyGalaxyTextureToNebula(texture) {
|
||
const nebulaGroup = scene.getObjectByName('space-nebula-model');
|
||
if (!nebulaGroup) {
|
||
return;
|
||
}
|
||
|
||
for (let index = 0; index < 5; index += 1) {
|
||
const plane = new THREE.Mesh(
|
||
new THREE.PlaneGeometry(190 + index * 26, 88 + index * 12, 1, 1),
|
||
new THREE.MeshBasicMaterial({
|
||
map: texture,
|
||
color: 0x9bbcff,
|
||
transparent: true,
|
||
opacity: 0.055 - index * 0.006,
|
||
blending: THREE.AdditiveBlending,
|
||
depthWrite: false,
|
||
side: THREE.DoubleSide
|
||
})
|
||
);
|
||
const theta = index * 1.22 + 0.35;
|
||
const radius = 420 + index * 28;
|
||
plane.position.set(Math.cos(theta) * radius, -70 + index * 32, Math.sin(theta) * radius);
|
||
plane.lookAt(0, 0, 0);
|
||
plane.rotateZ(-0.24 + index * 0.12);
|
||
nebulaGroup.add(plane);
|
||
}
|
||
}
|
||
|
||
function createBodyMesh(body) {
|
||
const highSegments = visualState.highConfig ? [256, 128] : [192, 96];
|
||
const geometry = new THREE.SphereGeometry(body.displayRadius, highSegments[0], highSegments[1]);
|
||
const texture = loadBodyTexture(body);
|
||
const material = body.id === 'sun'
|
||
? new THREE.MeshBasicMaterial({ map: texture, color: body.color, toneMapped: false })
|
||
: new THREE.MeshStandardMaterial({
|
||
map: texture,
|
||
color: body.color,
|
||
roughness: body.id === 'venus' ? 0.94 : (body.kind.includes('巨行星') ? 0.62 : 0.76),
|
||
metalness: 0,
|
||
emissive: body.id === 'earth' ? 0x061326 : 0x000000,
|
||
emissiveIntensity: body.id === 'earth' ? 0.08 : 0
|
||
});
|
||
const pivot = new THREE.Group();
|
||
pivot.name = `${body.id}-pivot`;
|
||
pivot.userData.body = body;
|
||
pivot.userData.baseRotationSpeed = rotationSpeedFor(body);
|
||
pivot.userData.axialTiltDeg = body.axialTiltDeg ?? 0;
|
||
|
||
const mesh = new THREE.Mesh(geometry, material);
|
||
mesh.name = body.id;
|
||
mesh.userData.body = body;
|
||
mesh.userData.bodyPivot = pivot;
|
||
mesh.userData.baseRotationSpeed = pivot.userData.baseRotationSpeed;
|
||
mesh.userData.axialTiltDeg = body.axialTiltDeg ?? 0;
|
||
const axialGroup = new THREE.Group();
|
||
axialGroup.name = `${body.id}-axial-group`;
|
||
axialGroup.rotation.z = THREE.MathUtils.degToRad(body.axialTiltDeg ?? 0);
|
||
const surfaceGroup = new THREE.Group();
|
||
surfaceGroup.name = `${body.id}-surface-group`;
|
||
mesh.userData.axialGroup = axialGroup;
|
||
mesh.userData.surfaceGroup = surfaceGroup;
|
||
surfaceGroup.add(mesh);
|
||
axialGroup.add(surfaceGroup);
|
||
pivot.add(axialGroup);
|
||
pivot.userData.planetMesh = mesh;
|
||
pivot.userData.axialGroup = axialGroup;
|
||
pivot.userData.surfaceGroup = surfaceGroup;
|
||
|
||
if (body.id === 'sun') {
|
||
const halo = new THREE.Mesh(
|
||
new THREE.SphereGeometry(body.displayRadius * 1.24, 160, 80),
|
||
new THREE.MeshBasicMaterial({ color: 0xffb23a, transparent: true, opacity: 0.16, blending: THREE.AdditiveBlending })
|
||
);
|
||
halo.name = `${body.id}-corona`;
|
||
mesh.userData.corona = halo;
|
||
mesh.add(halo);
|
||
const corona = new THREE.Mesh(
|
||
new THREE.SphereGeometry(body.displayRadius * 1.58, 160, 80),
|
||
new THREE.MeshBasicMaterial({ color: 0xff7f2a, transparent: true, opacity: 0.055, blending: THREE.AdditiveBlending, depthWrite: false })
|
||
);
|
||
corona.name = `${body.id}-outer-corona`;
|
||
mesh.userData.outerCorona = corona;
|
||
mesh.add(corona);
|
||
}
|
||
|
||
if (body.id === 'earth') {
|
||
const clouds = new THREE.Mesh(
|
||
new THREE.SphereGeometry(body.displayRadius * 1.018, 160, 80),
|
||
new THREE.MeshStandardMaterial({
|
||
map: loadOptionalTexture(body.cloudTexture) ?? createCloudTexture(),
|
||
color: 0xffffff,
|
||
transparent: true,
|
||
opacity: 0.32,
|
||
roughness: 0.9,
|
||
metalness: 0,
|
||
depthWrite: false
|
||
})
|
||
);
|
||
clouds.name = 'earth-cloud-layer';
|
||
mesh.userData.cloudLayer = clouds;
|
||
surfaceGroup.add(clouds);
|
||
}
|
||
|
||
if (body.id === 'mars') {
|
||
surfaceGroup.add(createPolarCap(body, 1));
|
||
surfaceGroup.add(createPolarCap(body, -1));
|
||
}
|
||
|
||
if (body.id !== 'sun') {
|
||
const nightRim = new THREE.Mesh(
|
||
new THREE.SphereGeometry(body.displayRadius * 1.012, 128, 64),
|
||
new THREE.MeshBasicMaterial({
|
||
color: body.id === 'earth' ? 0x73a8ff : body.color,
|
||
transparent: true,
|
||
opacity: body.id === 'earth' ? 0.045 : 0.032,
|
||
blending: THREE.AdditiveBlending,
|
||
depthWrite: false,
|
||
side: THREE.BackSide
|
||
})
|
||
);
|
||
nightRim.name = `${body.id}-night-rim`;
|
||
mesh.userData.nightRim = nightRim;
|
||
surfaceGroup.add(nightRim);
|
||
}
|
||
|
||
if (body.id === 'venus' || body.id === 'uranus' || body.id === 'neptune') {
|
||
const atmosphere = new THREE.Mesh(
|
||
new THREE.SphereGeometry(body.displayRadius * 1.045, 160, 80),
|
||
new THREE.MeshBasicMaterial({
|
||
color: body.id === 'venus' ? 0xffd089 : body.color,
|
||
transparent: true,
|
||
opacity: body.id === 'venus' ? 0.16 : 0.09,
|
||
blending: THREE.AdditiveBlending,
|
||
depthWrite: false
|
||
})
|
||
);
|
||
atmosphere.name = `${body.id}-atmosphere`;
|
||
mesh.userData.atmosphere = atmosphere;
|
||
surfaceGroup.add(atmosphere);
|
||
}
|
||
|
||
if (body.ringSystem) {
|
||
const ringSystem = createCalibratedRingSystem(body);
|
||
pivot.userData.ringSystem = ringSystem;
|
||
mesh.userData.ringSystem = ringSystem;
|
||
pivot.add(ringSystem);
|
||
}
|
||
|
||
if (['jupiter', 'saturn'].includes(body.id)) {
|
||
const limb = new THREE.Mesh(
|
||
new THREE.SphereGeometry(body.displayRadius * 1.025, 160, 80),
|
||
new THREE.MeshBasicMaterial({
|
||
color: body.id === 'jupiter' ? 0xffd5a3 : 0xf8dfa6,
|
||
transparent: true,
|
||
opacity: 0.055,
|
||
blending: THREE.AdditiveBlending,
|
||
depthWrite: false,
|
||
side: THREE.BackSide
|
||
})
|
||
);
|
||
limb.name = `${body.id}-limb-glow`;
|
||
mesh.userData.limbGlow = limb;
|
||
surfaceGroup.add(limb);
|
||
}
|
||
|
||
const outline = createBodyOutline(body);
|
||
mesh.userData.outline = outline;
|
||
mesh.add(outline);
|
||
|
||
return {
|
||
body,
|
||
pivot,
|
||
planetMesh: mesh,
|
||
ringGroup: pivot.userData.ringSystem ?? null,
|
||
axialGroup,
|
||
surfaceGroup,
|
||
labelAnchor: pivot
|
||
};
|
||
}
|
||
|
||
function loadBodyTexture(body) {
|
||
if (!body.texture) {
|
||
return createBodyTexture(body);
|
||
}
|
||
|
||
const texture = textureLoader.load(
|
||
body.texture,
|
||
loaded => {
|
||
configureTexture(loaded);
|
||
setAsyncProgress('textures', Math.min(100, 18 + (bodyMeshes.size / SOLAR_SYSTEM_BODIES.length) * 82), `${body.name} 高清纹理已载入`);
|
||
},
|
||
undefined,
|
||
() => {
|
||
const fallback = createBodyTexture(body);
|
||
texture.image = fallback.image;
|
||
texture.needsUpdate = true;
|
||
configureTexture(texture);
|
||
});
|
||
configureTexture(texture);
|
||
return texture;
|
||
}
|
||
|
||
function loadOptionalTexture(path) {
|
||
if (!path) {
|
||
return null;
|
||
}
|
||
|
||
const texture = textureLoader.load(path);
|
||
configureTexture(texture);
|
||
return texture;
|
||
}
|
||
|
||
function configureTexture(texture) {
|
||
texture.colorSpace = THREE.SRGBColorSpace;
|
||
texture.anisotropy = Math.min(renderer.capabilities.getMaxAnisotropy(), visualState.highConfig ? 16 : 8);
|
||
texture.generateMipmaps = true;
|
||
texture.minFilter = THREE.LinearMipmapLinearFilter;
|
||
texture.magFilter = THREE.LinearFilter;
|
||
if (texture.image) {
|
||
texture.needsUpdate = true;
|
||
}
|
||
return texture;
|
||
}
|
||
|
||
function createOrbitLine(body, color) {
|
||
const points = orbitSamples(body.id, new Date(), visualState.highConfig ? 1080 : 720).map(scaledPosition);
|
||
orbitPathPoints.set(body.id, points);
|
||
const geometry = new THREE.BufferGeometry().setFromPoints(points);
|
||
const material = new THREE.LineBasicMaterial({
|
||
color,
|
||
transparent: true,
|
||
opacity: 0.26
|
||
});
|
||
return new THREE.LineLoop(geometry, material);
|
||
}
|
||
|
||
function createOrbitTracer(body) {
|
||
const geometry = new THREE.SphereGeometry(Math.max(0.055, body.displayRadius * 0.08), 24, 12);
|
||
const material = new THREE.MeshBasicMaterial({
|
||
color: body.color,
|
||
transparent: true,
|
||
opacity: 0.72,
|
||
blending: THREE.AdditiveBlending,
|
||
depthWrite: false
|
||
});
|
||
const tracer = new THREE.Mesh(geometry, material);
|
||
tracer.name = `${body.id}-orbit-tracer`;
|
||
tracer.userData.effectKind = 'animation';
|
||
tracer.userData.bodyId = body.id;
|
||
tracer.userData.periodDays = orbitalPeriodDays(body.id);
|
||
tracer.userData.animate = (elapsed) => {
|
||
const path = orbitPathPoints.get(body.id);
|
||
if (!visualState.animationsEnabled || !path?.length) {
|
||
tracer.visible = false;
|
||
return;
|
||
}
|
||
tracer.visible = true;
|
||
const cycle = (elapsed / Math.max(3, Math.log10(tracer.userData.periodDays + 10) * 5.5)) % 1;
|
||
const index = Math.floor(cycle * path.length) % path.length;
|
||
const nextIndex = (index + 1) % path.length;
|
||
tracer.position.copy(path[index]).lerp(path[nextIndex], cycle * path.length - index);
|
||
tracer.scale.setScalar(1 + Math.sin(elapsed * 5.2) * 0.22);
|
||
};
|
||
effectObjects.push(tracer);
|
||
return tracer;
|
||
}
|
||
|
||
function createPlanetLabel(body) {
|
||
const label = document.createElement('div');
|
||
label.className = 'planet-label';
|
||
label.innerHTML = `<strong>${body.name}</strong><span>${body.group ?? body.kind}</span>`;
|
||
planetLabels.appendChild(label);
|
||
labelElements.set(body.id, label);
|
||
}
|
||
|
||
function updatePlanetLabels() {
|
||
const rect = stage.getBoundingClientRect();
|
||
const width = rect.width || 1;
|
||
const height = rect.height || 1;
|
||
const projected = new THREE.Vector3();
|
||
for (const body of SOLAR_SYSTEM_BODIES) {
|
||
const label = labelElements.get(body.id);
|
||
const bodyObject = bodyMeshes.get(body.id);
|
||
if (!label || !bodyObject || activeObject || earthFrame.classList.contains('is-visible')) {
|
||
label?.classList.remove('is-visible');
|
||
continue;
|
||
}
|
||
|
||
bodyObject.labelAnchor.getWorldPosition(projected).project(camera);
|
||
const mesh = bodyObject.planetMesh;
|
||
const shouldLabel = body.id === 'sun' || body.id === 'earth' || mesh === hoveredObject || mesh === activeObject || (width >= 1100 && ['jupiter', 'saturn', 'mars'].includes(body.id));
|
||
const visible = projected.z < 1 && projected.x > -1.14 && projected.x < 1.14 && projected.y > -1.14 && projected.y < 1.14;
|
||
if (!shouldLabel || !visible || (body.id === 'sun' && width < 780)) {
|
||
label.classList.remove('is-visible');
|
||
continue;
|
||
}
|
||
|
||
const x = (projected.x * 0.5 + 0.5) * width;
|
||
const y = (-projected.y * 0.5 + 0.5) * height - Math.max(16, body.displayRadius * 5);
|
||
label.style.left = `${Math.round(x)}px`;
|
||
label.style.top = `${Math.round(y)}px`;
|
||
label.classList.add('is-visible');
|
||
}
|
||
}
|
||
|
||
function createCalibratedRingSystem(body) {
|
||
const system = new THREE.Group();
|
||
system.name = `${body.id}-ring-system`;
|
||
system.userData.effectKind = 'animation';
|
||
const ringRotator = new THREE.Group();
|
||
ringRotator.name = `${body.id}-ring-rotator`;
|
||
ringRotator.userData.spinSpeed = body.id === 'jupiter' ? 0.006 : 0.014;
|
||
system.add(ringRotator);
|
||
const ring = body.ringSystem;
|
||
const segments = ring.segments?.length ? ring.segments : [{ inner: 1.35, outer: 2.1, opacity: ring.opacity ?? 0.32 }];
|
||
for (const segment of segments) {
|
||
const mesh = new THREE.Mesh(
|
||
new THREE.RingGeometry(body.displayRadius * segment.inner, body.displayRadius * segment.outer, visualState.highConfig ? 384 : 256, 4),
|
||
new THREE.MeshStandardMaterial({
|
||
map: createRingTexture(body, segment),
|
||
alphaMap: createRingAlphaTexture(segment, ring),
|
||
color: ring.color ?? body.color,
|
||
side: THREE.DoubleSide,
|
||
transparent: true,
|
||
opacity: segment.opacity ?? ring.opacity ?? 0.4,
|
||
roughness: 0.88,
|
||
metalness: 0,
|
||
depthWrite: false
|
||
})
|
||
);
|
||
mesh.name = `${body.id}-${segment.name ?? 'ring'}`;
|
||
mesh.userData.spinSpeed = body.id === 'jupiter' ? 0.012 : 0.028;
|
||
if (ring.arcs) {
|
||
ringRotator.add(createRingArc(body, segment));
|
||
}
|
||
ringRotator.add(mesh);
|
||
}
|
||
|
||
if (ring.dust) {
|
||
ringRotator.add(createRingDust(body));
|
||
}
|
||
|
||
applyRingPlane(system, body);
|
||
effectObjects.push(system);
|
||
return system;
|
||
}
|
||
|
||
function applyRingPlane(group, body) {
|
||
const tilt = THREE.MathUtils.degToRad(body.ringSystem?.tiltDeg ?? body.axialTiltDeg ?? 0);
|
||
group.rotation.set(Math.PI / 2, 0, tilt, 'XYZ');
|
||
}
|
||
|
||
function createRingArc(body, segment) {
|
||
const arc = new THREE.Line(
|
||
new THREE.BufferGeometry().setFromPoints(ringArcPoints(body.displayRadius * ((segment.inner + segment.outer) / 2), 0.18, 1.3)),
|
||
new THREE.LineBasicMaterial({
|
||
color: body.ringSystem.color ?? body.color,
|
||
transparent: true,
|
||
opacity: 0.62,
|
||
blending: THREE.AdditiveBlending
|
||
})
|
||
);
|
||
arc.name = `${body.id}-ring-arc`;
|
||
return arc;
|
||
}
|
||
|
||
function ringArcPoints(radius, start, end) {
|
||
const points = [];
|
||
for (let index = 0; index <= 80; index += 1) {
|
||
const angle = start + (end - start) * (index / 80);
|
||
points.push(new THREE.Vector3(Math.cos(angle) * radius, Math.sin(angle) * radius, 0.012));
|
||
}
|
||
return points;
|
||
}
|
||
|
||
function createStarField() {
|
||
const count = visualState.particlesEnabled ? 5400 : 2600;
|
||
const positions = new Float32Array(count * 3);
|
||
const colors = new Float32Array(count * 3);
|
||
const color = new THREE.Color();
|
||
for (let index = 0; index < count; index += 1) {
|
||
const radius = 260 + Math.random() * 420;
|
||
const theta = Math.random() * Math.PI * 2;
|
||
const phi = Math.acos((Math.random() * 2) - 1);
|
||
positions[index * 3] = radius * Math.sin(phi) * Math.cos(theta);
|
||
positions[index * 3 + 1] = radius * Math.cos(phi);
|
||
positions[index * 3 + 2] = radius * Math.sin(phi) * Math.sin(theta);
|
||
color.setHSL(0.56 + Math.random() * 0.12, 0.45, 0.68 + Math.random() * 0.28);
|
||
colors[index * 3] = color.r;
|
||
colors[index * 3 + 1] = color.g;
|
||
colors[index * 3 + 2] = color.b;
|
||
}
|
||
const geometry = new THREE.BufferGeometry();
|
||
geometry.setAttribute('position', new THREE.BufferAttribute(positions, 3));
|
||
geometry.setAttribute('color', new THREE.BufferAttribute(colors, 3));
|
||
const material = new THREE.PointsMaterial({
|
||
size: 1.05,
|
||
sizeAttenuation: true,
|
||
vertexColors: true,
|
||
transparent: true,
|
||
opacity: 0.86
|
||
});
|
||
scene.add(new THREE.Points(geometry, material));
|
||
}
|
||
|
||
function createSolarWindParticles() {
|
||
if (!visualState.particlesEnabled) {
|
||
return;
|
||
}
|
||
|
||
const count = 1500;
|
||
const positions = new Float32Array(count * 3);
|
||
const colors = new Float32Array(count * 3);
|
||
const phases = new Float32Array(count);
|
||
const speeds = new Float32Array(count);
|
||
const color = new THREE.Color();
|
||
for (let index = 0; index < count; index += 1) {
|
||
const arm = index % 5;
|
||
const radius = 6 + Math.random() * 62;
|
||
const theta = arm * (Math.PI * 2 / 5) + radius * 0.18 + (Math.random() - 0.5) * 0.42;
|
||
const y = Math.sin(radius * 0.18 + arm) * 2.4 + (Math.random() - 0.5) * 2.2;
|
||
positions[index * 3] = Math.cos(theta) * radius;
|
||
positions[index * 3 + 1] = y;
|
||
positions[index * 3 + 2] = Math.sin(theta) * radius;
|
||
color.setHSL(0.075 + Math.random() * 0.085, 0.86, 0.54 + Math.random() * 0.32);
|
||
colors[index * 3] = color.r;
|
||
colors[index * 3 + 1] = color.g;
|
||
colors[index * 3 + 2] = color.b;
|
||
phases[index] = Math.random() * Math.PI * 2;
|
||
speeds[index] = 0.004 + Math.random() * 0.007;
|
||
}
|
||
const geometry = new THREE.BufferGeometry();
|
||
geometry.setAttribute('position', new THREE.BufferAttribute(positions, 3));
|
||
geometry.setAttribute('color', new THREE.BufferAttribute(colors, 3));
|
||
geometry.setAttribute('phase', new THREE.BufferAttribute(phases, 1));
|
||
const material = new THREE.PointsMaterial({
|
||
size: 0.34,
|
||
sizeAttenuation: true,
|
||
vertexColors: true,
|
||
transparent: true,
|
||
opacity: 0.62,
|
||
blending: THREE.AdditiveBlending,
|
||
depthWrite: false
|
||
});
|
||
const particles = new THREE.Points(geometry, material);
|
||
particles.name = 'solar-wind-particles';
|
||
particles.userData.effectKind = 'particle';
|
||
particles.userData.animate = (elapsed) => {
|
||
const array = geometry.attributes.position.array;
|
||
for (let index = 0; index < count; index += 1) {
|
||
const offset = index * 3;
|
||
const phase = phases[index] + elapsed * 0.45;
|
||
const radius = Math.hypot(array[offset], array[offset + 2]);
|
||
const nextRadius = radius > 68 ? 6 : radius + speeds[index];
|
||
const theta = Math.atan2(array[offset + 2], array[offset]) + 0.0024 + Math.sin(phase) * 0.0008;
|
||
array[offset] = Math.cos(theta) * nextRadius;
|
||
array[offset + 1] = Math.sin(nextRadius * 0.18 + phases[index] + elapsed * 0.32) * 2.6;
|
||
array[offset + 2] = Math.sin(theta) * nextRadius;
|
||
}
|
||
geometry.attributes.position.needsUpdate = true;
|
||
};
|
||
effectObjects.push(particles);
|
||
scene.add(particles);
|
||
}
|
||
|
||
function createKuiperDustBelt() {
|
||
if (!visualState.particlesEnabled) {
|
||
return;
|
||
}
|
||
|
||
const count = 2300;
|
||
const positions = new Float32Array(count * 3);
|
||
const colors = new Float32Array(count * 3);
|
||
const color = new THREE.Color();
|
||
for (let index = 0; index < count; index += 1) {
|
||
const radius = 60 + Math.random() * 22;
|
||
const theta = Math.random() * Math.PI * 2;
|
||
positions[index * 3] = Math.cos(theta) * radius;
|
||
positions[index * 3 + 1] = (Math.random() - 0.5) * (2.4 + Math.random() * 4.4);
|
||
positions[index * 3 + 2] = Math.sin(theta) * radius;
|
||
color.setHSL(0.58 + Math.random() * 0.1, 0.35 + Math.random() * 0.25, 0.46 + Math.random() * 0.3);
|
||
colors[index * 3] = color.r;
|
||
colors[index * 3 + 1] = color.g;
|
||
colors[index * 3 + 2] = color.b;
|
||
}
|
||
const geometry = new THREE.BufferGeometry();
|
||
geometry.setAttribute('position', new THREE.BufferAttribute(positions, 3));
|
||
geometry.setAttribute('color', new THREE.BufferAttribute(colors, 3));
|
||
const material = new THREE.PointsMaterial({
|
||
size: 0.16,
|
||
sizeAttenuation: true,
|
||
vertexColors: true,
|
||
transparent: true,
|
||
opacity: 0.32,
|
||
blending: THREE.AdditiveBlending,
|
||
depthWrite: false
|
||
});
|
||
const belt = new THREE.Points(geometry, material);
|
||
belt.name = 'kuiper-dust-belt';
|
||
belt.userData.effectKind = 'particle';
|
||
belt.userData.animate = (elapsed) => {
|
||
belt.rotation.y = elapsed * 0.006;
|
||
belt.rotation.x = Math.sin(elapsed * 0.08) * 0.018;
|
||
};
|
||
effectObjects.push(belt);
|
||
scene.add(belt);
|
||
}
|
||
|
||
function createBodyOutline(body) {
|
||
const outline = new THREE.Mesh(
|
||
new THREE.SphereGeometry(body.displayRadius * 1.055, 128, 64),
|
||
new THREE.MeshBasicMaterial({
|
||
color: body.id === 'sun' ? 0xffd36a : 0xb9d7ff,
|
||
transparent: true,
|
||
opacity: 0,
|
||
blending: THREE.AdditiveBlending,
|
||
depthWrite: false,
|
||
side: THREE.BackSide
|
||
})
|
||
);
|
||
outline.name = `${body.id}-hover-outline`;
|
||
outline.userData.targetOpacity = 0;
|
||
return outline;
|
||
}
|
||
|
||
function createCloudTexture() {
|
||
const canvas = document.createElement('canvas');
|
||
canvas.width = visualState.highConfig ? 2048 : 1024;
|
||
canvas.height = visualState.highConfig ? 1024 : 512;
|
||
const ctx = canvas.getContext('2d');
|
||
const width = canvas.width;
|
||
const height = canvas.height;
|
||
ctx.clearRect(0, 0, width, height);
|
||
ctx.fillStyle = 'rgba(255,255,255,0.72)';
|
||
for (let index = 0; index < 160; index += 1) {
|
||
const x = Math.random() * width;
|
||
const y = height * (0.12 + Math.random() * 0.72);
|
||
const rx = width * (0.018 + Math.random() * 0.075);
|
||
const ry = height * (0.006 + Math.random() * 0.022);
|
||
ctx.globalAlpha = 0.16 + Math.random() * 0.42;
|
||
ctx.beginPath();
|
||
ctx.ellipse(x, y, rx, ry, Math.random() * Math.PI, 0, Math.PI * 2);
|
||
ctx.fill();
|
||
}
|
||
ctx.globalAlpha = 1;
|
||
const texture = new THREE.CanvasTexture(canvas);
|
||
texture.colorSpace = THREE.SRGBColorSpace;
|
||
texture.wrapS = THREE.RepeatWrapping;
|
||
texture.wrapT = THREE.ClampToEdgeWrapping;
|
||
return configureTexture(texture);
|
||
}
|
||
|
||
function createRingTexture(body = {}, segment = {}) {
|
||
const canvas = document.createElement('canvas');
|
||
canvas.width = 2048;
|
||
canvas.height = 64;
|
||
const ctx = canvas.getContext('2d');
|
||
const width = canvas.width;
|
||
const gradient = ctx.createLinearGradient(0, 0, width, 0);
|
||
if (body.id === 'jupiter') {
|
||
gradient.addColorStop(0, '#4c4137');
|
||
gradient.addColorStop(0.42, '#bba890');
|
||
gradient.addColorStop(1, '#5b4c40');
|
||
} else if (body.id === 'uranus') {
|
||
gradient.addColorStop(0, '#23343a');
|
||
gradient.addColorStop(0.55, '#c8efec');
|
||
gradient.addColorStop(1, '#29464a');
|
||
} else if (body.id === 'neptune') {
|
||
gradient.addColorStop(0, '#152650');
|
||
gradient.addColorStop(0.52, '#8fb1ff');
|
||
gradient.addColorStop(1, '#182d66');
|
||
} else {
|
||
gradient.addColorStop(0, '#8b7757');
|
||
gradient.addColorStop(0.18, '#d5c08a');
|
||
gradient.addColorStop(0.32, '#f5e6b7');
|
||
gradient.addColorStop(0.38, '#5e5448');
|
||
gradient.addColorStop(0.42, '#241f1d');
|
||
gradient.addColorStop(0.52, '#c9ad78');
|
||
gradient.addColorStop(0.68, '#f2dfaa');
|
||
gradient.addColorStop(0.84, '#aa8d62');
|
||
gradient.addColorStop(1, '#5c5144');
|
||
}
|
||
ctx.fillStyle = gradient;
|
||
ctx.fillRect(0, 0, canvas.width, canvas.height);
|
||
|
||
const lines = body.id === 'saturn' ? 230 : 120;
|
||
for (let index = 0; index < lines; index += 1) {
|
||
const x = Math.random() * width;
|
||
const alpha = 0.06 + Math.random() * 0.16;
|
||
ctx.fillStyle = Math.random() > 0.5 ? `rgba(255, 244, 205, ${alpha})` : `rgba(49, 38, 31, ${alpha})`;
|
||
ctx.fillRect(x, 0, 1 + Math.random() * 6, canvas.height);
|
||
}
|
||
|
||
const texture = new THREE.CanvasTexture(canvas);
|
||
texture.wrapS = THREE.RepeatWrapping;
|
||
texture.wrapT = THREE.ClampToEdgeWrapping;
|
||
return configureTexture(texture);
|
||
}
|
||
|
||
function createRingAlphaTexture(segment = {}, ring = {}) {
|
||
const canvas = document.createElement('canvas');
|
||
canvas.width = 2048;
|
||
canvas.height = 64;
|
||
const ctx = canvas.getContext('2d');
|
||
const width = canvas.width;
|
||
const gradient = ctx.createLinearGradient(0, 0, width, 0);
|
||
const base = Math.max(0.04, Math.min(0.92, segment.opacity ?? ring.opacity ?? 0.45));
|
||
gradient.addColorStop(0, `rgba(255,255,255,${base * 0.18})`);
|
||
gradient.addColorStop(0.16, `rgba(255,255,255,${base * 0.72})`);
|
||
gradient.addColorStop(0.35, `rgba(255,255,255,${base})`);
|
||
gradient.addColorStop(0.405, `rgba(255,255,255,${base * 0.1})`);
|
||
gradient.addColorStop(0.43, `rgba(255,255,255,${base * 0.04})`);
|
||
gradient.addColorStop(0.48, `rgba(255,255,255,${base * 0.62})`);
|
||
gradient.addColorStop(0.7, `rgba(255,255,255,${base * 0.92})`);
|
||
gradient.addColorStop(0.9, `rgba(255,255,255,${base * 0.42})`);
|
||
gradient.addColorStop(1, `rgba(255,255,255,${base * 0.08})`);
|
||
ctx.fillStyle = gradient;
|
||
ctx.fillRect(0, 0, canvas.width, canvas.height);
|
||
|
||
for (let index = 0; index < 80; index += 1) {
|
||
ctx.fillStyle = `rgba(0,0,0,${0.04 + Math.random() * 0.12})`;
|
||
const x = Math.random() * width;
|
||
ctx.fillRect(x, 0, 1 + Math.random() * 8, canvas.height);
|
||
}
|
||
|
||
const texture = new THREE.CanvasTexture(canvas);
|
||
texture.wrapS = THREE.RepeatWrapping;
|
||
texture.wrapT = THREE.ClampToEdgeWrapping;
|
||
return configureTexture(texture);
|
||
}
|
||
|
||
function createRingDust(body) {
|
||
const count = visualState.particlesEnabled ? 1200 : 360;
|
||
const positions = new Float32Array(count * 3);
|
||
const colors = new Float32Array(count * 3);
|
||
const color = new THREE.Color();
|
||
for (let index = 0; index < count; index += 1) {
|
||
const band = Math.random();
|
||
const radius = body.displayRadius * (1.36 + Math.pow(band, 0.72) * 0.88);
|
||
const theta = Math.random() * Math.PI * 2;
|
||
positions[index * 3] = Math.cos(theta) * radius;
|
||
positions[index * 3 + 1] = (Math.random() - 0.5) * body.displayRadius * 0.045;
|
||
positions[index * 3 + 2] = Math.sin(theta) * radius;
|
||
color.setHSL(0.115 + Math.random() * 0.025, 0.24 + Math.random() * 0.2, 0.58 + Math.random() * 0.3);
|
||
colors[index * 3] = color.r;
|
||
colors[index * 3 + 1] = color.g;
|
||
colors[index * 3 + 2] = color.b;
|
||
}
|
||
const geometry = new THREE.BufferGeometry();
|
||
geometry.setAttribute('position', new THREE.BufferAttribute(positions, 3));
|
||
geometry.setAttribute('color', new THREE.BufferAttribute(colors, 3));
|
||
const dust = new THREE.Points(
|
||
geometry,
|
||
new THREE.PointsMaterial({
|
||
size: 0.032,
|
||
vertexColors: true,
|
||
transparent: true,
|
||
opacity: 0.82,
|
||
sizeAttenuation: true,
|
||
blending: THREE.AdditiveBlending,
|
||
depthWrite: false
|
||
})
|
||
);
|
||
dust.name = `${body.id}-ring-dust`;
|
||
dust.userData.effectKind = 'particle';
|
||
return dust;
|
||
}
|
||
|
||
function createPolarCap(body, sign) {
|
||
const cap = new THREE.Mesh(
|
||
new THREE.SphereGeometry(body.displayRadius * 1.006, 96, 32, 0, Math.PI * 2, 0, Math.PI * 0.16),
|
||
new THREE.MeshBasicMaterial({
|
||
color: 0xf3efe5,
|
||
transparent: true,
|
||
opacity: 0.72,
|
||
blending: THREE.AdditiveBlending,
|
||
depthWrite: false
|
||
})
|
||
);
|
||
cap.name = `${body.id}-${sign > 0 ? 'north' : 'south'}-polar-cap`;
|
||
cap.rotation.x = sign > 0 ? 0 : Math.PI;
|
||
cap.position.y = sign * body.displayRadius * 0.006;
|
||
return cap;
|
||
}
|
||
|
||
function createSeamlessSpaceTexture() {
|
||
const canvas = document.createElement('canvas');
|
||
canvas.width = 4096;
|
||
canvas.height = 2048;
|
||
const ctx = canvas.getContext('2d');
|
||
const width = canvas.width;
|
||
const height = canvas.height;
|
||
const bg = ctx.createLinearGradient(0, 0, 0, height);
|
||
bg.addColorStop(0, '#02040a');
|
||
bg.addColorStop(0.48, '#081225');
|
||
bg.addColorStop(1, '#03050a');
|
||
ctx.fillStyle = bg;
|
||
ctx.fillRect(0, 0, width, height);
|
||
|
||
const drawWrappedEllipse = (cx, cy, rx, ry, rotation, stops) => {
|
||
for (const offset of [-width, 0, width]) {
|
||
const x = cx + offset;
|
||
const gradient = ctx.createRadialGradient(x, cy, 10, x, cy, Math.max(rx, ry));
|
||
for (const [stop, color] of stops) {
|
||
gradient.addColorStop(stop, color);
|
||
}
|
||
ctx.fillStyle = gradient;
|
||
ctx.beginPath();
|
||
ctx.ellipse(x, cy, rx, ry, rotation, 0, Math.PI * 2);
|
||
ctx.fill();
|
||
}
|
||
};
|
||
|
||
const drawWrappedStar = (x, y, size, color) => {
|
||
for (const offset of [-width, 0, width]) {
|
||
ctx.fillStyle = color;
|
||
ctx.fillRect(x + offset, y, size, size);
|
||
}
|
||
};
|
||
|
||
for (let band = 0; band < 9; band += 1) {
|
||
const cx = ((band / 8) * width + 180 * Math.sin(band * 1.73)) % width;
|
||
const cy = height * (0.48 + Math.sin(band * 1.17) * 0.085);
|
||
drawWrappedEllipse(cx, cy, 820 + band * 32, 210 + band * 11, -0.18 + band * 0.018, [
|
||
[0, `rgba(${84 + band * 10}, ${122 + band * 7}, 214, 0.22)`],
|
||
[0.42, 'rgba(118, 78, 166, 0.115)'],
|
||
[1, 'rgba(3, 5, 10, 0)']
|
||
]);
|
||
}
|
||
|
||
for (let cloud = 0; cloud < 32; cloud += 1) {
|
||
drawWrappedEllipse(
|
||
Math.random() * width,
|
||
height * (0.22 + Math.random() * 0.56),
|
||
160 + Math.random() * 360,
|
||
42 + Math.random() * 110,
|
||
Math.random() * Math.PI,
|
||
[
|
||
[0, `rgba(${82 + Math.random() * 80}, ${104 + Math.random() * 88}, ${170 + Math.random() * 70}, ${0.06 + Math.random() * 0.085})`],
|
||
[0.58, 'rgba(82, 70, 136, 0.035)'],
|
||
[1, 'rgba(3, 5, 10, 0)']
|
||
]
|
||
);
|
||
}
|
||
|
||
for (let index = 0; index < 5200; index += 1) {
|
||
const x = Math.random() * width;
|
||
const y = Math.random() * height;
|
||
const alpha = 0.22 + Math.random() * 0.72;
|
||
const size = Math.random() < 0.94 ? 1 : 2;
|
||
drawWrappedStar(x, y, size, `rgba(230, 239, 255, ${alpha})`);
|
||
}
|
||
|
||
for (let cluster = 0; cluster < 18; cluster += 1) {
|
||
const cx = Math.random() * width;
|
||
const cy = Math.random() * height;
|
||
for (let index = 0; index < 44; index += 1) {
|
||
const angle = Math.random() * Math.PI * 2;
|
||
const radius = Math.random() * 88;
|
||
drawWrappedStar(
|
||
cx + Math.cos(angle) * radius,
|
||
cy + Math.sin(angle) * radius,
|
||
1,
|
||
`rgba(245, 249, 255, ${0.38 + Math.random() * 0.5})`
|
||
);
|
||
}
|
||
}
|
||
|
||
const texture = new THREE.CanvasTexture(canvas);
|
||
texture.wrapS = THREE.RepeatWrapping;
|
||
texture.wrapT = THREE.ClampToEdgeWrapping;
|
||
return configureTexture(texture);
|
||
}
|
||
|
||
function createBodyTexture(body) {
|
||
const canvas = document.createElement('canvas');
|
||
const highPriorityTexture = new Set(['sun', 'earth', 'jupiter', 'saturn']);
|
||
const textureScale = visualState.textureScale;
|
||
canvas.width = Math.round((highPriorityTexture.has(body.id) ? visualState.textureResolution : visualState.textureResolution / 2) * textureScale);
|
||
canvas.height = Math.round(canvas.width / 2);
|
||
const ctx = canvas.getContext('2d');
|
||
const width = canvas.width;
|
||
const height = canvas.height;
|
||
const base = new THREE.Color(body.color);
|
||
ctx.fillStyle = body.color;
|
||
ctx.fillRect(0, 0, width, height);
|
||
|
||
if (body.id === 'earth') {
|
||
drawEarthTexture(ctx);
|
||
} else if (body.id === 'jupiter') {
|
||
drawBands(ctx, ['#c99567', '#f0d3a0', '#b67b58', '#ead8b8', '#8f5f48']);
|
||
} else if (body.id === 'saturn') {
|
||
drawBands(ctx, ['#e9d7a6', '#c9ad78', '#f5e6bd', '#b89665']);
|
||
} else if (body.id === 'sun') {
|
||
drawSunTexture(ctx);
|
||
} else {
|
||
for (let i = 0; i < 120; i += 1) {
|
||
const shade = base.clone().offsetHSL((Math.random() - 0.5) * 0.04, 0, (Math.random() - 0.5) * 0.18);
|
||
ctx.fillStyle = shade.getStyle();
|
||
ctx.globalAlpha = 0.13 + Math.random() * 0.18;
|
||
ctx.beginPath();
|
||
ctx.ellipse(
|
||
Math.random() * width,
|
||
Math.random() * height,
|
||
width * (0.025 + Math.random() * 0.09),
|
||
height * (0.016 + Math.random() * 0.07),
|
||
Math.random() * Math.PI,
|
||
0,
|
||
Math.PI * 2
|
||
);
|
||
ctx.fill();
|
||
}
|
||
ctx.globalAlpha = 1;
|
||
}
|
||
|
||
const texture = new THREE.CanvasTexture(canvas);
|
||
return configureTexture(texture);
|
||
}
|
||
|
||
function drawBands(ctx, colors) {
|
||
const width = ctx.canvas.width;
|
||
const height = ctx.canvas.height;
|
||
const bandHeight = height / colors.length;
|
||
for (let index = 0; index < colors.length; index += 1) {
|
||
ctx.fillStyle = colors[index];
|
||
ctx.fillRect(0, Math.floor(index * bandHeight), width, Math.ceil(bandHeight + 1));
|
||
}
|
||
ctx.globalAlpha = 0.18;
|
||
for (let index = 0; index < 84; index += 1) {
|
||
ctx.fillStyle = index % 2 === 0 ? '#ffffff' : '#4c2b1e';
|
||
ctx.fillRect(0, Math.random() * height, width, 1 + Math.random() * 6);
|
||
}
|
||
ctx.globalAlpha = 1;
|
||
}
|
||
|
||
function drawSunTexture(ctx) {
|
||
const width = ctx.canvas.width;
|
||
const height = ctx.canvas.height;
|
||
const gradient = ctx.createRadialGradient(width / 2, height / 2, width * 0.025, width / 2, height / 2, width * 0.52);
|
||
gradient.addColorStop(0, '#fff5af');
|
||
gradient.addColorStop(0.36, '#ffd36a');
|
||
gradient.addColorStop(1, '#f07b24');
|
||
ctx.fillStyle = gradient;
|
||
ctx.fillRect(0, 0, width, height);
|
||
ctx.globalAlpha = 0.3;
|
||
for (let index = 0; index < 220; index += 1) {
|
||
ctx.strokeStyle = index % 2 === 0 ? '#fff6b9' : '#ff8b31';
|
||
ctx.beginPath();
|
||
const y = Math.random() * height;
|
||
ctx.moveTo(0, y);
|
||
for (let x = 0; x <= width; x += width / 18) {
|
||
ctx.lineTo(x, y + Math.sin(x * 0.014 + index) * (height * 0.035));
|
||
}
|
||
ctx.stroke();
|
||
}
|
||
ctx.globalAlpha = 1;
|
||
}
|
||
|
||
function drawEarthTexture(ctx) {
|
||
const width = ctx.canvas.width;
|
||
const height = ctx.canvas.height;
|
||
const ocean = ctx.createLinearGradient(0, 0, width, height);
|
||
ocean.addColorStop(0, '#174a8a');
|
||
ocean.addColorStop(1, '#2c89bd');
|
||
ctx.fillStyle = ocean;
|
||
ctx.fillRect(0, 0, width, height);
|
||
ctx.fillStyle = '#6fa86d';
|
||
const land = [
|
||
[0.172, 0.344, 0.108, 0.102], [0.293, 0.5, 0.082, 0.078], [0.498, 0.359, 0.137, 0.109],
|
||
[0.596, 0.555, 0.09, 0.094], [0.746, 0.492, 0.113, 0.094], [0.82, 0.695, 0.063, 0.063]
|
||
];
|
||
for (const [x, y, rx, ry] of land) {
|
||
ctx.beginPath();
|
||
ctx.ellipse(x * width, y * height, rx * width, ry * height, 0.35, 0, Math.PI * 2);
|
||
ctx.fill();
|
||
}
|
||
ctx.fillStyle = 'rgba(244, 248, 255, 0.9)';
|
||
for (let index = 0; index < 58; index += 1) {
|
||
ctx.beginPath();
|
||
ctx.ellipse(
|
||
Math.random() * width,
|
||
height * (0.12 + Math.random() * 0.74),
|
||
width * (0.025 + Math.random() * 0.07),
|
||
height * (0.012 + Math.random() * 0.028),
|
||
Math.random() * Math.PI,
|
||
0,
|
||
Math.PI * 2
|
||
);
|
||
ctx.fill();
|
||
}
|
||
}
|
||
|
||
function updatePlanetPositions(date) {
|
||
const positions = approximateSolarSystem(date);
|
||
for (const entry of positions) {
|
||
localPositions.set(entry.id, entry.heliocentricAu);
|
||
}
|
||
applyPositions(localPositions, { immediate: true, source: 'local' });
|
||
}
|
||
|
||
function applyPositions(positionMap, options = {}) {
|
||
for (const body of PLANETS) {
|
||
const bodyObject = bodyMeshes.get(body.id);
|
||
const vector = positionMap.get(body.id) ?? localPositions.get(body.id);
|
||
if (!bodyObject || !vector) {
|
||
continue;
|
||
}
|
||
const pivot = bodyObject.pivot;
|
||
const next = scaledPosition(vector);
|
||
if (options.immediate) {
|
||
pivot.position.copy(next);
|
||
pivot.userData.targetPosition = null;
|
||
pivot.userData.ephemerisCorrection = null;
|
||
} else {
|
||
const orbitPath = createOrbitCorrectionPath(body.id, pivot.position, next);
|
||
pivot.userData.ephemerisCorrection = {
|
||
started: clock.elapsedTime,
|
||
duration: 1.35 + body.displayRadius * 0.08,
|
||
from: pivot.position.clone(),
|
||
to: next,
|
||
path: orbitPath,
|
||
arcLift: 0.14 + Math.min(0.75, next.length() * 0.006)
|
||
};
|
||
pivot.userData.targetPosition = next;
|
||
}
|
||
}
|
||
}
|
||
|
||
function scaledPosition(auVector) {
|
||
const v = new THREE.Vector3(auVector.x, auVector.z * 0.42, auVector.y);
|
||
const length = Math.max(v.length(), 0.0001);
|
||
const radius = orbitRadiusFor(length);
|
||
return v.normalize().multiplyScalar(radius);
|
||
}
|
||
|
||
function orbitRadiusFor(au) {
|
||
if (!Number.isFinite(au) || au <= 0) {
|
||
return 0;
|
||
}
|
||
return 7 + Math.log(1 + au * 2.6) * 14.5;
|
||
}
|
||
|
||
function updateLocalOrbitMotion(elapsed) {
|
||
const now = Date.now();
|
||
if (now - lastLocalPositionUpdate < 45000) {
|
||
return;
|
||
}
|
||
|
||
lastLocalPositionUpdate = now;
|
||
const positions = approximateSolarSystem(new Date(now));
|
||
for (const entry of positions) {
|
||
localPositions.set(entry.id, entry.heliocentricAu);
|
||
const bodyObject = bodyMeshes.get(entry.id);
|
||
const pivot = bodyObject?.pivot;
|
||
if (pivot && !pivot.userData.ephemerisCorrection) {
|
||
pivot.userData.targetPosition = scaledPosition(entry.heliocentricAu);
|
||
}
|
||
}
|
||
}
|
||
|
||
function applyEphemerisCorrection(mesh, elapsed) {
|
||
const owner = mesh.userData.bodyPivot ?? mesh;
|
||
const correction = owner.userData.ephemerisCorrection;
|
||
if (!correction) {
|
||
if (owner.userData.targetPosition) {
|
||
owner.position.lerp(owner.userData.targetPosition, 0.018);
|
||
}
|
||
return;
|
||
}
|
||
|
||
const progress = Math.min(1, (elapsed - correction.started) / correction.duration);
|
||
const eased = 1 - Math.pow(1 - progress, 3);
|
||
const pathPosition = sampleCorrectionPath(correction.path, eased);
|
||
if (pathPosition) {
|
||
owner.position.copy(pathPosition);
|
||
} else {
|
||
owner.position.lerpVectors(correction.from, correction.to, eased);
|
||
}
|
||
owner.position.y += Math.sin(progress * Math.PI) * correction.arcLift;
|
||
if (progress >= 1) {
|
||
owner.position.copy(correction.to);
|
||
owner.userData.ephemerisCorrection = null;
|
||
}
|
||
}
|
||
|
||
function createOrbitCorrectionPath(bodyId, currentPosition, targetPosition) {
|
||
const orbit = orbitPathPoints.get(bodyId);
|
||
if (!orbit || orbit.length < 8) {
|
||
return [currentPosition.clone(), targetPosition.clone()];
|
||
}
|
||
|
||
const startIndex = nearestOrbitIndex(orbit, currentPosition);
|
||
const endIndex = nearestOrbitIndex(orbit, targetPosition);
|
||
const forward = [];
|
||
const backward = [];
|
||
for (let index = startIndex; index !== endIndex; index = (index + 1) % orbit.length) {
|
||
forward.push(orbit[index]);
|
||
}
|
||
forward.push(orbit[endIndex]);
|
||
for (let index = startIndex; index !== endIndex; index = (index - 1 + orbit.length) % orbit.length) {
|
||
backward.push(orbit[index]);
|
||
}
|
||
backward.push(orbit[endIndex]);
|
||
const route = pathLength(forward) <= pathLength(backward) ? forward : backward;
|
||
return [currentPosition.clone(), ...route.map(point => point.clone()), targetPosition.clone()];
|
||
}
|
||
|
||
function nearestOrbitIndex(points, position) {
|
||
let bestIndex = 0;
|
||
let bestDistance = Number.POSITIVE_INFINITY;
|
||
for (let index = 0; index < points.length; index += 1) {
|
||
const distance = points[index].distanceToSquared(position);
|
||
if (distance < bestDistance) {
|
||
bestDistance = distance;
|
||
bestIndex = index;
|
||
}
|
||
}
|
||
return bestIndex;
|
||
}
|
||
|
||
function pathLength(points) {
|
||
let length = 0;
|
||
for (let index = 1; index < points.length; index += 1) {
|
||
length += points[index - 1].distanceTo(points[index]);
|
||
}
|
||
return length;
|
||
}
|
||
|
||
function sampleCorrectionPath(points, progress) {
|
||
if (!points || points.length === 0) {
|
||
return null;
|
||
}
|
||
if (points.length === 1) {
|
||
return points[0].clone();
|
||
}
|
||
|
||
const lengths = [];
|
||
let total = 0;
|
||
for (let index = 1; index < points.length; index += 1) {
|
||
const segment = points[index - 1].distanceTo(points[index]);
|
||
lengths.push(segment);
|
||
total += segment;
|
||
}
|
||
if (total <= 0.0001) {
|
||
return points.at(-1).clone();
|
||
}
|
||
|
||
let distance = total * progress;
|
||
for (let index = 0; index < lengths.length; index += 1) {
|
||
if (distance <= lengths[index]) {
|
||
return points[index].clone().lerp(points[index + 1], distance / Math.max(lengths[index], 0.0001));
|
||
}
|
||
distance -= lengths[index];
|
||
}
|
||
return points.at(-1).clone();
|
||
}
|
||
|
||
function handlePointerMove(event) {
|
||
const intersections = intersectBodies(event);
|
||
const next = intersections.length > 0 ? intersections[0].object : null;
|
||
if (hoveredObject !== next) {
|
||
setHoverOutline(hoveredObject, false);
|
||
hoveredObject = next;
|
||
setHoverOutline(hoveredObject, true);
|
||
}
|
||
renderer.domElement.style.cursor = hoveredObject ? 'pointer' : 'grab';
|
||
updatePlanetTooltip(event, hoveredObject);
|
||
}
|
||
|
||
function handleClick(event) {
|
||
const intersections = intersectBodies(event);
|
||
if (intersections.length === 0) {
|
||
clearSelection();
|
||
return;
|
||
}
|
||
|
||
const selected = intersections[0].object;
|
||
const body = selected.userData.body;
|
||
if (body.id === 'earth') {
|
||
showEarthScene();
|
||
return;
|
||
}
|
||
if (body.semiMajorAxisAu) {
|
||
showPlanetDetail(body);
|
||
return;
|
||
}
|
||
hidePlanetTooltip();
|
||
selectBody(selected);
|
||
}
|
||
|
||
function intersectBodies(event) {
|
||
const rect = renderer.domElement.getBoundingClientRect();
|
||
pointer.x = ((event.clientX - rect.left) / rect.width) * 2 - 1;
|
||
pointer.y = -((event.clientY - rect.top) / rect.height) * 2 + 1;
|
||
raycaster.setFromCamera(pointer, camera);
|
||
return raycaster.intersectObjects(selectable, false);
|
||
}
|
||
|
||
function selectBody(mesh) {
|
||
activeObject = mesh;
|
||
hint.classList.add('is-hidden');
|
||
for (const item of selectable) {
|
||
setBodyScale(item, item === mesh ? 1.16 : 1);
|
||
setHoverOutline(item, item === mesh);
|
||
}
|
||
for (const [id, line] of orbitLines) {
|
||
line.material.opacity = id === mesh.name ? 0.72 : 0.18;
|
||
}
|
||
|
||
const body = mesh.userData.body;
|
||
bodyName.textContent = body.name;
|
||
bodyKind.textContent = body.kind;
|
||
bodyDiameter.textContent = body.diameter;
|
||
bodyDistance.textContent = body.solarDistance;
|
||
bodyOrbit.textContent = body.orbitalPeriod;
|
||
bodyRotation.textContent = body.rotationPeriod;
|
||
bodyMoons.textContent = body.moons;
|
||
bodySummary.textContent = body.summary;
|
||
bodyComposition.textContent = [
|
||
body.group ?? body.kind,
|
||
body.composition ?? body.kind,
|
||
body.materialProfile
|
||
].filter(Boolean).join(' · ');
|
||
bodyHighlights.replaceChildren(...(body.observationHighlights ?? []).map(text => listItem(text)));
|
||
bodyMissions.textContent = (body.missions ?? []).join(' / ') || '暂无代表任务';
|
||
bodyVisualNotes.textContent = body.ringSystem
|
||
? `${body.ringSystem.label} · ${body.visualNotes ?? ''}`
|
||
: (body.visualNotes ?? body.textureCredit ?? '高清纹理与真实光照渲染');
|
||
bodyFacts.replaceChildren(...body.facts.map(text => listItem(text)));
|
||
bodySources.replaceChildren(...body.sources.concat(body.textureCredit ? [body.textureCredit] : []).map(text => listItem(text)));
|
||
infoPanel.classList.add('is-visible');
|
||
infoPanel.setAttribute('aria-hidden', 'false');
|
||
focusCameraOn(mesh);
|
||
}
|
||
|
||
function clearSelection() {
|
||
activeObject = null;
|
||
focusTween = null;
|
||
cameraTransition = null;
|
||
camera.userData.focusPosition = null;
|
||
infoPanel.classList.remove('is-visible');
|
||
infoPanel.setAttribute('aria-hidden', 'true');
|
||
if (loading.classList.contains('is-hidden')) {
|
||
hint.classList.remove('is-hidden');
|
||
}
|
||
for (const item of selectable) {
|
||
setBodyScale(item, 1);
|
||
setHoverOutline(item, item === hoveredObject);
|
||
}
|
||
for (const line of orbitLines.values()) {
|
||
line.material.opacity = 0.26;
|
||
}
|
||
}
|
||
|
||
function listItem(text) {
|
||
const item = document.createElement('li');
|
||
item.textContent = text;
|
||
return item;
|
||
}
|
||
|
||
function setBodyScale(mesh, multiplier) {
|
||
const body = mesh.userData.body;
|
||
const base = body.id === 'sun' ? 1 : responsive.scale;
|
||
const target = mesh.userData.bodyPivot ?? mesh;
|
||
target.scale.setScalar(base * multiplier);
|
||
}
|
||
|
||
function setHoverOutline(mesh, enabled) {
|
||
if (!mesh?.userData?.outline) {
|
||
return;
|
||
}
|
||
|
||
mesh.userData.outline.userData.targetOpacity = enabled ? (mesh === activeObject ? 0.42 : 0.28) : 0;
|
||
const label = labelElements.get(mesh.userData.body?.id);
|
||
if (label) {
|
||
label.style.transform = enabled ? 'translate(-50%, -58%) scale(1.06)' : 'translate(-50%, -50%) scale(1)';
|
||
}
|
||
}
|
||
|
||
function focusCameraOn(mesh) {
|
||
const target = getBodyWorldPosition(mesh);
|
||
const direction = camera.position.clone().sub(controls.target).normalize();
|
||
const distance = mesh.userData.body.id === 'sun' ? 19 : 11 + mesh.userData.body.displayRadius * 2.1;
|
||
startCameraTransition({
|
||
started: clock.elapsedTime,
|
||
duration: 0.74,
|
||
fromCamera: camera.position.clone(),
|
||
toCamera: target.clone().add(direction.multiplyScalar(distance)),
|
||
fromTarget: controls.target.clone(),
|
||
toTarget: target,
|
||
returnToTour: false
|
||
});
|
||
}
|
||
|
||
function getBodyWorldPosition(mesh) {
|
||
const anchor = mesh?.userData?.bodyPivot ?? mesh;
|
||
return anchor
|
||
? anchor.getWorldPosition(reusableWorldPosition).clone()
|
||
: new THREE.Vector3();
|
||
}
|
||
|
||
function defaultCameraPosition() {
|
||
return new THREE.Vector3(0, responsive.cameraDistance * 0.34 + responsive.y, responsive.cameraDistance);
|
||
}
|
||
|
||
function startCameraTransition(options) {
|
||
focusTween = null;
|
||
cameraTransition = {
|
||
started: options.started ?? clock.elapsedTime,
|
||
duration: options.duration ?? 0.72,
|
||
fromCamera: options.fromCamera ?? camera.position.clone(),
|
||
toCamera: options.toCamera.clone(),
|
||
fromTarget: options.fromTarget ?? controls.target.clone(),
|
||
toTarget: options.toTarget.clone(),
|
||
returnToTour: Boolean(options.returnToTour)
|
||
};
|
||
}
|
||
|
||
function cancelFocusTween() {
|
||
userInteracted = true;
|
||
focusTween = null;
|
||
cameraTransition = null;
|
||
if (returnTourTimer) {
|
||
clearTimeout(returnTourTimer);
|
||
returnTourTimer = null;
|
||
}
|
||
camera.userData.focusPosition = null;
|
||
}
|
||
|
||
function updatePlanetTooltip(event, mesh) {
|
||
if (!mesh || activeObject || earthFrame.classList.contains('is-visible')) {
|
||
hidePlanetTooltip();
|
||
return;
|
||
}
|
||
|
||
const body = mesh.userData.body;
|
||
tooltipName.textContent = body.name;
|
||
tooltipKind.textContent = `${body.kind} · ${body.id === 'sun' ? '自发光恒星' : '反射太阳光'}`;
|
||
const rect = stage.getBoundingClientRect();
|
||
const tooltipWidth = planetTooltip.offsetWidth || 140;
|
||
const tooltipHeight = planetTooltip.offsetHeight || 52;
|
||
const x = Math.min(rect.width - tooltipWidth - 14, Math.max(12, event.clientX - rect.left + 14));
|
||
const y = Math.min(rect.height - tooltipHeight - 14, Math.max(12, event.clientY - rect.top + 14));
|
||
planetTooltip.style.left = `${x}px`;
|
||
planetTooltip.style.top = `${y}px`;
|
||
planetTooltip.classList.add('is-visible');
|
||
planetTooltip.setAttribute('aria-hidden', 'false');
|
||
}
|
||
|
||
function hidePlanetTooltip() {
|
||
planetTooltip.classList.remove('is-visible');
|
||
planetTooltip.setAttribute('aria-hidden', 'true');
|
||
}
|
||
|
||
function showEarthScene() {
|
||
showPlanetDetail(SOLAR_SYSTEM_BODIES.find(body => body.id === 'earth'));
|
||
}
|
||
|
||
function showPlanetDetail(body) {
|
||
if (!body) {
|
||
return;
|
||
}
|
||
clearSelection();
|
||
hidePlanetTooltip();
|
||
const detailUrl = body.id === 'earth'
|
||
? './world-civilizations-globe.html?embedded=1'
|
||
: (body.detailPage ?? `./planet-detail.html?id=${encodeURIComponent(body.id)}&embedded=1`);
|
||
if (detailLoadedId !== body.id || earthIframe.getAttribute('src') !== detailUrl) {
|
||
earthIframe.src = detailUrl;
|
||
detailLoadedId = body.id;
|
||
}
|
||
earthIframe.title = `${body.name}详情`;
|
||
earthFrame.classList.add('is-visible');
|
||
earthFrame.setAttribute('aria-hidden', 'false');
|
||
backButton.classList.add('is-visible');
|
||
}
|
||
|
||
function showSolarSystem() {
|
||
earthFrame.classList.remove('is-visible');
|
||
earthFrame.setAttribute('aria-hidden', 'true');
|
||
backButton.classList.remove('is-visible');
|
||
clearSelection();
|
||
userInteracted = true;
|
||
resize();
|
||
resetCameraToSolarSystem();
|
||
}
|
||
|
||
function resetCameraToSolarSystem() {
|
||
camera.up.set(0, 1, 0);
|
||
controls.target.copy(controls.target);
|
||
startCameraTransition({
|
||
started: clock.elapsedTime,
|
||
duration: 0.86,
|
||
fromCamera: camera.position.clone(),
|
||
toCamera: defaultCameraPosition(),
|
||
fromTarget: controls.target.clone(),
|
||
toTarget: defaultTarget,
|
||
returnToTour: true
|
||
});
|
||
}
|
||
|
||
function applyHostMessage(message) {
|
||
const payload = typeof message === 'string' ? safeJson(message) : message;
|
||
if (!payload) {
|
||
return;
|
||
}
|
||
|
||
if (payload.type === 'solar:settings') {
|
||
applySolarSettings(payload);
|
||
return;
|
||
}
|
||
|
||
if (payload.type === 'performanceMode') {
|
||
hostLightMode = Boolean(payload.lightMode);
|
||
clock.getDelta();
|
||
return;
|
||
}
|
||
|
||
if (payload.type === 'ephemeris:complete') {
|
||
if (!acceptEphemerisRequest(payload)) {
|
||
return;
|
||
}
|
||
completeEphemerisSync(payload);
|
||
return;
|
||
}
|
||
|
||
if (payload.type !== 'ephemeris:sync' || !Array.isArray(payload.planets)) {
|
||
return;
|
||
}
|
||
if (!acceptEphemerisRequest(payload)) {
|
||
return;
|
||
}
|
||
if (ephemerisState.terminal) {
|
||
return;
|
||
}
|
||
|
||
remoteTargets.clear();
|
||
for (const planet of payload.planets) {
|
||
if (planet?.id && planet.heliocentricAu) {
|
||
remoteTargets.set(planet.id, planet.heliocentricAu);
|
||
}
|
||
}
|
||
applyPositions(remoteTargets, { source: payload.source });
|
||
ephemerisState.phase = payload.phase ?? 'initial';
|
||
ephemerisState.source = payload.source ?? '';
|
||
const source = payload.source ?? '';
|
||
const timeText = payload.epochUtc ? new Date(payload.epochUtc).toLocaleString('zh-CN') : '当前时间';
|
||
const approximateSource = payload.phase !== 'precise' || source === 'local' || /approx/i.test(source);
|
||
syncStatus.textContent = approximateSource
|
||
? `本地近似星历已同步 · ${timeText}`
|
||
: `精确星历已同步:${source} · ${timeText}`;
|
||
setAsyncProgress(
|
||
'ephemeris',
|
||
approximateSource ? 78 : 96,
|
||
approximateSource ? `近似星历可用,正在校验精确源 · ${timeText}` : `${source} 精确星历已同步 · ${timeText}`
|
||
);
|
||
}
|
||
|
||
function acceptEphemerisRequest(payload) {
|
||
if (!payload.requestId) {
|
||
return true;
|
||
}
|
||
if (!ephemerisState.requestId) {
|
||
ephemerisState.requestId = payload.requestId;
|
||
return true;
|
||
}
|
||
return payload.requestId === ephemerisState.requestId;
|
||
}
|
||
|
||
function completeEphemerisSync(payload) {
|
||
const status = payload.status ?? (payload.preciseAvailable ? 'success' : 'unavailable');
|
||
const source = payload.source || ephemerisState.source || '本地近似';
|
||
const messages = {
|
||
success: `精确星历校验完成:${source}`,
|
||
unavailable: '精确源暂不可用,本地近似星历继续可用',
|
||
failed: '精确星历校验失败,本地近似星历继续可用',
|
||
timeout: '精确源响应超时,本地近似星历继续可用'
|
||
};
|
||
ephemerisState.terminal = true;
|
||
ephemerisState.status = status;
|
||
ephemerisState.message = messages[status] ?? messages.unavailable;
|
||
syncStatus.textContent = ephemerisState.message;
|
||
setAsyncProgress('ephemeris', 100, ephemerisState.message);
|
||
}
|
||
|
||
function applySolarSettings(payload) {
|
||
if (typeof payload.animationsEnabled === 'boolean') {
|
||
visualState.animationsEnabled = payload.animationsEnabled;
|
||
}
|
||
if (typeof payload.hardwareAccelerationEnabled === 'boolean') {
|
||
visualState.hardwareAccelerationEnabled = payload.hardwareAccelerationEnabled;
|
||
}
|
||
visualState.particlesEnabled = visualState.animationsEnabled &&
|
||
visualState.hardwareAccelerationEnabled &&
|
||
visualState.highConfig;
|
||
setAsyncProgress(
|
||
'effects',
|
||
100,
|
||
visualState.particlesEnabled ? '粒子、星环尘埃和轨道流光已开启' : '粒子效果已按当前配置收敛'
|
||
);
|
||
for (const object of effectObjects) {
|
||
object.visible = object.userData.effectKind === 'particle'
|
||
? visualState.particlesEnabled
|
||
: visualState.animationsEnabled;
|
||
}
|
||
resize();
|
||
}
|
||
|
||
function safeJson(value) {
|
||
try {
|
||
return JSON.parse(value);
|
||
} catch {
|
||
return null;
|
||
}
|
||
}
|
||
|
||
function resize() {
|
||
const width = Math.max(1, stage.clientWidth);
|
||
const height = Math.max(1, stage.clientHeight);
|
||
renderer.setPixelRatio(Math.min(window.devicePixelRatio || 1, visualState.pixelRatioCap));
|
||
renderer.setSize(width, height, false);
|
||
camera.aspect = width / height;
|
||
applyResponsiveScene(width, height);
|
||
camera.updateProjectionMatrix();
|
||
}
|
||
|
||
function applyResponsiveScene(width, height) {
|
||
const ratio = width / height;
|
||
if (width < 620 || ratio < 0.82) {
|
||
responsive.scale = 0.74;
|
||
responsive.cameraDistance = 86;
|
||
responsive.y = 12;
|
||
camera.fov = 54;
|
||
} else if (width < 980 || ratio < 1.18) {
|
||
responsive.scale = 0.86;
|
||
responsive.cameraDistance = 78;
|
||
responsive.y = 9;
|
||
camera.fov = 49;
|
||
} else {
|
||
responsive.scale = 1;
|
||
responsive.cameraDistance = 70;
|
||
responsive.y = 7;
|
||
camera.fov = 44;
|
||
}
|
||
|
||
if (!activeObject && !userInteracted) {
|
||
camera.position.set(0, responsive.cameraDistance * 0.34 + responsive.y, responsive.cameraDistance);
|
||
controls.target.set(0, 0, 0);
|
||
} else if (cameraTransition?.returnToTour) {
|
||
cameraTransition.toCamera.copy(defaultCameraPosition());
|
||
cameraTransition.toTarget.copy(defaultTarget);
|
||
}
|
||
controls.minDistance = responsive.cameraDistance * 0.28;
|
||
controls.maxDistance = responsive.cameraDistance * 1.9;
|
||
for (const mesh of selectable) {
|
||
setBodyScale(mesh, mesh === activeObject ? 1.16 : 1);
|
||
}
|
||
}
|
||
|
||
function animate() {
|
||
requestAnimationFrame(animate);
|
||
if (hostLightMode) {
|
||
clock.getDelta();
|
||
return;
|
||
}
|
||
const delta = Math.min(clock.getDelta(), 0.05);
|
||
const elapsed = clock.elapsedTime;
|
||
const animationFactor = visualState.animationsEnabled ? 1 : 0;
|
||
updateRenderQuality(delta);
|
||
updateLocalOrbitMotion(elapsed);
|
||
|
||
for (const mesh of selectable) {
|
||
const body = mesh.userData.body;
|
||
const surfaceGroup = mesh.userData.surfaceGroup;
|
||
if (surfaceGroup) {
|
||
surfaceGroup.rotation.y += delta * mesh.userData.baseRotationSpeed * animationFactor;
|
||
} else {
|
||
mesh.rotation.y += delta * mesh.userData.baseRotationSpeed * animationFactor;
|
||
}
|
||
applyEphemerisCorrection(mesh, elapsed);
|
||
animateBodyEffects(mesh, delta, elapsed);
|
||
}
|
||
|
||
for (const object of effectObjects) {
|
||
if (object.visible && object.userData.animate) {
|
||
object.userData.animate(elapsed, delta);
|
||
}
|
||
spinEffectObject(object, delta, animationFactor);
|
||
}
|
||
|
||
if (cameraTransition) {
|
||
const progress = Math.min(1, (elapsed - cameraTransition.started) / cameraTransition.duration);
|
||
const eased = 1 - Math.pow(1 - progress, 3);
|
||
camera.position.lerpVectors(cameraTransition.fromCamera, cameraTransition.toCamera, eased);
|
||
reusableFocusTarget.lerpVectors(cameraTransition.fromTarget, cameraTransition.toTarget, eased);
|
||
controls.target.copy(reusableFocusTarget);
|
||
if (progress >= 1) {
|
||
if (cameraTransition.returnToTour) {
|
||
cameraTransition = null;
|
||
if (returnTourTimer) {
|
||
clearTimeout(returnTourTimer);
|
||
}
|
||
returnTourTimer = setTimeout(() => {
|
||
userInteracted = false;
|
||
returnTourTimer = null;
|
||
}, 320);
|
||
} else {
|
||
cameraTransition = null;
|
||
}
|
||
}
|
||
} else if (!activeObject && !userInteracted) {
|
||
const radius = responsive.cameraDistance;
|
||
camera.position.x = Math.sin(elapsed * 0.045) * radius * 0.22;
|
||
camera.position.z = Math.cos(elapsed * 0.045) * radius;
|
||
camera.position.y = radius * 0.32 + responsive.y;
|
||
controls.target.lerp(defaultTarget, 0.04);
|
||
} else if (focusTween) {
|
||
const progress = Math.min(1, (elapsed - focusTween.started) / focusTween.duration);
|
||
const eased = 1 - Math.pow(1 - progress, 3);
|
||
camera.position.lerpVectors(focusTween.fromCamera, focusTween.toCamera, eased);
|
||
reusableFocusTarget.lerpVectors(focusTween.fromTarget, focusTween.toTarget, eased);
|
||
controls.target.copy(reusableFocusTarget);
|
||
if (progress >= 1) {
|
||
focusTween = null;
|
||
}
|
||
}
|
||
|
||
controls.update();
|
||
renderer.render(scene, camera);
|
||
updatePlanetLabels();
|
||
}
|
||
|
||
function spinEffectObject(object, delta, animationFactor) {
|
||
if (object.userData?.spinSpeed) {
|
||
object.rotation.z += delta * object.userData.spinSpeed * animationFactor;
|
||
}
|
||
|
||
for (const child of object.children ?? []) {
|
||
if (child.userData?.effectKind === 'particle') {
|
||
child.visible = visualState.particlesEnabled;
|
||
}
|
||
spinEffectObject(child, delta, animationFactor);
|
||
}
|
||
}
|
||
|
||
function animateBodyEffects(mesh, delta, elapsed) {
|
||
const outline = mesh.userData.outline;
|
||
if (outline) {
|
||
const target = outline.userData.targetOpacity || 0;
|
||
outline.material.opacity += (target - outline.material.opacity) * 0.18;
|
||
outline.scale.setScalar(1 + Math.sin(elapsed * 3.2) * 0.018);
|
||
}
|
||
|
||
if (mesh.userData.cloudLayer) {
|
||
mesh.userData.cloudLayer.rotation.y += delta * 0.032;
|
||
}
|
||
if (mesh.userData.atmosphere) {
|
||
mesh.userData.atmosphere.material.opacity +=
|
||
((mesh === hoveredObject || mesh === activeObject ? 0.18 : 0.09) - mesh.userData.atmosphere.material.opacity) * 0.06;
|
||
}
|
||
if (mesh.userData.nightRim) {
|
||
const target = mesh === hoveredObject || mesh === activeObject ? 0.075 : 0.038;
|
||
mesh.userData.nightRim.material.opacity += (target - mesh.userData.nightRim.material.opacity) * 0.05;
|
||
}
|
||
if (mesh.userData.corona) {
|
||
mesh.userData.corona.scale.setScalar(1 + Math.sin(elapsed * 1.7) * 0.035);
|
||
}
|
||
if (mesh.userData.outerCorona) {
|
||
mesh.userData.outerCorona.rotation.y -= delta * 0.05;
|
||
mesh.userData.outerCorona.scale.setScalar(1 + Math.sin(elapsed * 1.1) * 0.05);
|
||
}
|
||
for (const child of mesh.children) {
|
||
if (child.userData.effectKind === 'particle') {
|
||
child.visible = visualState.particlesEnabled;
|
||
}
|
||
}
|
||
}
|
||
|
||
function updateRenderQuality(delta) {
|
||
renderStats.elapsed += delta;
|
||
renderStats.frames += 1;
|
||
if (renderStats.elapsed < 4 || renderStats.qualityReduced) {
|
||
return;
|
||
}
|
||
|
||
const fps = renderStats.frames / renderStats.elapsed;
|
||
if (fps < 38 && visualState.particlesEnabled) {
|
||
renderStats.qualityReduced = true;
|
||
visualState.particlesEnabled = false;
|
||
visualState.pixelRatioCap = Math.min(visualState.pixelRatioCap, 1.75);
|
||
for (const object of effectObjects) {
|
||
if (object.userData.effectKind === 'particle') {
|
||
object.visible = false;
|
||
}
|
||
}
|
||
resize();
|
||
const message = `帧率约 ${Math.round(fps)} FPS,已自动降低粒子负载`;
|
||
setAsyncProgress('quality', 100, message);
|
||
} else if (!renderStats.qualityReduced) {
|
||
setAsyncProgress('quality', 100, `渲染帧率约 ${Math.round(fps)} FPS,质量保持当前配置`);
|
||
}
|
||
renderStats.elapsed = 0;
|
||
renderStats.frames = 0;
|
||
}
|
||
|
||
function rotationSpeedFor(body) {
|
||
if (body.id === 'sun') {
|
||
return 0.08;
|
||
}
|
||
const period = SELF_ROTATION_PERIOD_HOURS[body.id] ?? 24;
|
||
const sign = period < 0 ? -1 : 1;
|
||
const hours = Math.max(Math.abs(period), 1);
|
||
return sign * Math.max(0.028, Math.min(0.52, 5.2 / hours));
|
||
}
|
||
</script>
|
||
</body>
|
||
</html>
|