Files
YMhut-box-C-/src/box-winUI/Assets/home-globe/solar-system-home.html
T

2650 lines
90 KiB
HTML
Raw 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.
<!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>