Files
Toolbox/public/tools/thought_lab/labs/blackhole_sim/index.html
T
yangxiangyuan ea379116cc feat: 新增思想实验室模块与配套功能
- 新增5个物理仿真实验室:黑洞模拟、日地轨道、3D太阳系、π推导、拉格朗日点
- 新增思想实验室后端路由与鉴权逻辑
- 首页新增思想实验室页签与前端渲染逻辑
- 修复中韩半导体监控标的配置错误
- 新增市场技能推送白名单过滤逻辑与单元测试
2026-08-06 13:39:48 +08:00

530 lines
23 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.
<!-- ============================================================
思想实验室 · labs/blackhole_sim · 黑洞模拟
广义相对论光线追踪(史瓦西度规零测地线)教学模拟器。
独立 lab:单文件自包含,不引用其他 lab 的任何资源。
服务端配置:src/server/thought_lab/labs/blackhole_sim/config.json
============================================================ -->
<!DOCTYPE html>
<html lang="zh-CN">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>黑洞模拟器 - 广义相对论光线追踪 · 教学版</title>
<style>
* { box-sizing: border-box; margin: 0; padding: 0; }
html, body { height: 100%; background: #000; overflow: hidden;
font: 13px/1.65 "Microsoft YaHei", "PingFang SC", sans-serif; color: #cfd8ea; }
#glcanvas { position: fixed; inset: 0; width: 100%; height: 100%; display: block; cursor: grab; }
#glcanvas:active { cursor: grabbing; }
/* 左侧控制/教学面板 */
#panel {
position: fixed; top: 12px; left: 12px; z-index: 10; width: 320px;
max-height: calc(100% - 24px); overflow-y: auto;
background: rgba(6, 10, 20, 0.82); border: 1px solid rgba(120, 160, 255, 0.3);
border-radius: 10px; padding: 14px 16px; backdrop-filter: blur(6px);
}
#panel::-webkit-scrollbar { width: 6px; }
#panel::-webkit-scrollbar-thumb { background: #33415e; border-radius: 3px; }
#panel h1 { font-size: 16px; color: #fff; letter-spacing: 1px; margin-bottom: 2px; }
#panel h1 small { font-size: 11px; color: #7fa0e0; font-weight: normal; }
.tabs { display: flex; gap: 6px; margin: 10px 0; }
.tab-btn { flex: 1; padding: 6px 0; text-align: center; cursor: pointer;
background: #16203a; border: 1px solid #2c3c60; border-radius: 6px; color: #9db4e0; }
.tab-btn.active { background: #27407a; color: #fff; border-color: #5a8cff; }
.sec { margin: 10px 0 4px; color: #ffd479; font-size: 12px; font-weight: bold;
border-left: 3px solid #ffd479; padding-left: 6px; }
.row { display: flex; align-items: center; justify-content: space-between; margin: 6px 0; gap: 8px; }
.row label { color: #9db4e0; white-space: nowrap; }
input[type=range] { flex: 1; accent-color: #5a8cff; cursor: pointer; }
select, button.preset { background: #16203a; color: #cfd8ea; border: 1px solid #2c3c60;
border-radius: 5px; padding: 3px 8px; cursor: pointer; }
button.preset:hover { border-color: #5a8cff; }
.val { color: #fff; min-width: 70px; text-align: right; }
.chk { display: flex; align-items: center; gap: 6px; color: #cfd8ea; cursor: pointer; }
.chk input { accent-color: #5a8cff; }
.data-grid { display: grid; grid-template-columns: auto 1fr; gap: 2px 10px; font-size: 12px; }
.data-grid .k { color: #7d8db0; }
.data-grid .v { color: #ffe9b0; }
details { margin: 8px 0; }
summary { cursor: pointer; color: #8fb0ff; font-weight: bold; }
details p { margin: 6px 0 6px 4px; color: #aebad2; font-size: 12px; text-align: justify; }
details p b { color: #ffd479; }
.hint { color: #66759a; font-size: 11px; margin-top: 4px; }
/* 轨道实验室 */
#orbitlab { display: none; }
#orbitlab.show { display: block; }
#view3d.hide { display: none; }
#orbit-canvas { width: 100%; background: #050810; border: 1px solid #223052; border-radius: 8px; }
#orbit-info { font-size: 12px; color: #aebad2; margin-top: 6px; }
#orbit-info b { color: #ffe9b0; }
#load-error { position: fixed; inset: 0; z-index: 99; display: none;
align-items: center; justify-content: center; background: #000;
color: #ff8080; font-size: 16px; text-align: center; }
</style>
</head>
<body>
<canvas id="glcanvas"></canvas>
<div id="panel">
<h1>黑洞模拟器 <small>GR Ray Tracing · 教学版</small></h1>
<div class="tabs">
<div class="tab-btn active" id="tab-view" onclick="switchTab('view')">3D 相对论视图</div>
<div class="tab-btn" id="tab-orbit" onclick="switchTab('orbit')">轨道实验室</div>
</div>
<!-- ============ 3D 视图控制 ============ -->
<div id="view3d">
<div class="sec">黑洞参数</div>
<div class="row">
<label>质量</label>
<input type="range" id="mass" min="0" max="100" step="1" value="36">
<span class="val" id="mass-val"></span>
</div>
<div class="row">
<label>预设</label>
<button class="preset" onclick="setMassPreset(10)">恒星级 10M☉</button>
<button class="preset" onclick="setMassPreset(4.3e6)">Sgr A*</button>
<button class="preset" onclick="setMassPreset(6.5e9)">M87*</button>
</div>
<div class="data-grid" id="derived"></div>
<div class="sec">物理效应开关</div>
<div class="row">
<label class="chk"><input type="checkbox" id="tg-lens" checked> 引力透镜(光线弯曲)</label>
<label class="chk"><input type="checkbox" id="tg-doppler" checked> 多普勒束流</label>
</div>
<div class="row">
<label class="chk"><input type="checkbox" id="tg-disk" checked> 吸积盘</label>
<label class="chk"><input type="checkbox" id="tg-stars" checked> 背景星空</label>
</div>
<div class="sec">相机</div>
<div class="row">
<label>距离</label>
<input type="range" id="cam-dist" min="4" max="60" step="0.5" value="16">
<span class="val" id="cam-dist-val">16 rs</span>
</div>
<div class="row">
<label>视角</label>
<button class="preset" onclick="setView(0.09)">侧视(盘边缘)</button>
<button class="preset" onclick="setView(0.55)">斜 30°</button>
<button class="preset" onclick="setView(1.35)">俯视</button>
</div>
<div class="row">
<label>画质</label>
<select id="quality">
<option value="0.5">低(流畅)</option>
<option value="0.75" selected>中</option>
<option value="1">高</option>
</select>
</div>
<div class="hint">鼠标左键拖拽旋转 · 滚轮缩放 · 吸积盘内侧更亮更蓝(温度 T∝r⁻³ᐟ⁴),朝向你运动的一侧因相对论性束流更亮。</div>
<div class="sec">结构与真实观测</div>
<details>
<summary>事件视界 / 光子球 / ISCO / 阴影</summary>
<p><b>事件视界 r=rs</b>:光也无法逃逸的边界,rs=2GM/c²。</p>
<p><b>光子球 r=1.5rs</b>:光子可绕黑洞做(不稳定)圆轨道。画面中细亮的"光子环"即多次绕转的光。</p>
<p><b>ISCO r=3rs</b>:最内稳定圆轨道,吸积盘内缘在此(物质自此螺旋坠入)。</p>
<p><b>黑洞阴影</b>:背景光被捕获形成的暗区,直径≈5.2rs,比视界大得多——这正是 EHT 拍到的"环状亮圈包着暗区"。</p>
</details>
<details>
<summary>人类如何真的"看到"黑洞</summary>
<p><b>事件视界望远镜 EHT</b>:全球毫米波射电望远镜组成地球口径的 VLBI 阵列,2019 年发布 M87*(65亿M☉)首张照片,2022 年发布 Sgr A*。观测波长 1.3mm,亮环即透镜化的吸积流,暗区即阴影。</p>
<p><b>LIGO/Virgo</b>:2015 年首次直接探测双黑洞并合引力波,验证强场广义相对论。</p>
<p><b>GRAVITY(VLT)</b>:追踪 S2 恒星绕 Sgr A* 的轨道,测得史瓦西进动,与"轨道实验室"页同一物理。</p>
</details>
</div>
<!-- ============ 轨道实验室 ============ -->
<div id="orbitlab">
<div class="sec">测试粒子轨道(史瓦西度规)</div>
<div class="row">
<button class="preset" onclick="orbitPreset('isco')">ISCO 圆轨道 r=3rs</button>
<button class="preset" onclick="orbitPreset('prec')">进动椭圆</button>
<button class="preset" onclick="orbitPreset('plunge')">俘获坠入</button>
</div>
<canvas id="orbit-canvas" width="560" height="560"></canvas>
<div id="orbit-info"></div>
<div class="hint">蓝圈=事件视界(1rs) · 青圈=光子球(1.5rs) · 黄圈=ISCO(3rs)。椭圆轨道近日点每圈前移 Δφ≈6πGM/[c²a(1−e²)],S2 恒星绕银心的进动已被 GRAVITY 实测验证。</div>
</div>
</div>
<div id="load-error">WebGL 不可用,无法运行黑洞模拟。</div>
<script>
'use strict';
/* =====================================================================
* 黑洞教学模拟器
* 物理内核:史瓦西度规零测地线(光子)数值积分
* d²x/dλ² = -(3/2) rs h² x / r⁵ (h 为比角动量,守恒)
* 该式精确描述史瓦西时空中光的弯曲,自然涌现:
* 黑洞阴影、光子环、吸积盘上下镜像(透镜化)、爱因斯坦环
* 吸积盘辐射:T∝r^(-3/4),多普勒束流 δ=1/[γ(1-βcosθ)],
* 引力红移 g=√(1-rs/r),观测强度 ∝ g³(相对论性束流)
* ================================================================== */
const canvas = document.getElementById('glcanvas');
const gl = canvas.getContext('webgl', { antialias: false, preserveDrawingBuffer: true }) || canvas.getContext('experimental-webgl');
if (!gl) { document.getElementById('load-error').style.display = 'flex'; throw new Error('WebGL unavailable'); }
/* ---------------- 着色器 ---------------- */
const VERT = `
attribute vec2 aPos;
void main(){ gl_Position = vec4(aPos, 0.0, 1.0); }
`;
const FRAG = `
#ifdef GL_FRAGMENT_PRECISION_HIGH
precision highp float;
#else
precision mediump float;
#endif
uniform vec2 uRes;
uniform vec3 uCamPos, uCamR, uCamU, uCamF;
uniform float uTime, uLens, uDoppler, uDisk, uStars, uAspect;
const float RS = 1.0; // 史瓦西半径(几何单位)
const float DISK_IN = 3.0; // ISCO
const float DISK_OUT = 13.0;
const int MAX_STEPS = 300;
float hash13(vec3 p){ p = fract(p*0.1031); p += dot(p, p.yzx+33.33); return fract((p.x+p.y)*p.z); }
float hash12(vec2 p){ vec3 p3 = fract(vec3(p.xyx)*0.1031); p3 += dot(p3, p3.yzx+33.33); return fract((p3.x+p3.y)*p3.z); }
float noise2(vec2 p){
vec2 i = floor(p), f = fract(p); f = f*f*(3.0-2.0*f);
return mix(mix(hash12(i), hash12(i+vec2(1,0)), f.x),
mix(hash12(i+vec2(0,1)), hash12(i+vec2(1,1)), f.x), f.y);
}
// 黑体色温近似色带:冷(橙红) -> 热(蓝白)
vec3 tempColor(float t){
t = clamp(t, 0.0, 1.0);
vec3 c1 = vec3(1.0, 0.25, 0.06);
vec3 c2 = vec3(1.0, 0.72, 0.30);
vec3 c3 = vec3(1.0, 0.98, 0.92);
vec3 c4 = vec3(0.62, 0.76, 1.0);
if (t < 0.35) return mix(c1, c2, t/0.35);
if (t < 0.7) return mix(c2, c3, (t-0.35)/0.35);
return mix(c3, c4, (t-0.7)/0.3);
}
// 背景星空(随光线方向,透镜会自然扭曲它)
vec3 starfield(vec3 d){
vec3 col = vec3(0.0);
if (uStars < 0.5) return col;
vec3 q = floor(d*230.0);
float h = hash13(q);
float star = smoothstep(0.9986, 1.0, h);
float bri = 0.4 + 0.6*hash13(q+11.7);
col += star * bri * mix(vec3(0.8,0.9,1.0), vec3(1.0,0.85,0.7), hash13(q+3.1));
// 微弱银河带
float band = exp(-abs(d.y)*6.0);
col += band * 0.05 * noise2(d.xz*4.0 + d.y*2.0) * vec3(0.7,0.8,1.0);
return col;
}
// 吸积盘局部辐射(含多普勒束流 + 引力红移)
vec3 diskShade(vec3 pos, vec3 rayDir){
float r = length(pos);
// 温度分布 T ∝ r^(-3/4),以 ISCO 为 1 归一
float tN = pow(DISK_IN / r, 0.75);
// 开普勒旋转湍流纹理(角速度 ∝ r^-1.5)
float ang = atan(pos.z, pos.x);
float om = 1.0 / pow(r, 1.5);
float n = noise2(vec2(ang*2.0 + uTime*om*3.0, r*1.5));
n = 0.65 + 0.7*n;
float g = sqrt(max(1.0 - RS/r, 0.0)); // 引力红移
float dop = 1.0;
if (uDoppler > 0.5) {
float beta = sqrt(0.5 / max(r - 1.0, 0.05)); // 局部静止观者测得的圆轨道速度/c
beta = min(beta, 0.94);
vec3 tangent = normalize(vec3(pos.z, 0.0, -pos.x)); // 顺行方向
vec3 toCam = normalize(-rayDir);
float cosT = dot(tangent, toCam);
float gam = 1.0 / sqrt(1.0 - beta*beta);
dop = 1.0 / (gam * (1.0 - beta*cosT)); // 多普勒因子
}
float gTot = g * dop;
vec3 col = tempColor(clamp(tN * gTot, 0.0, 1.0));
float inten = pow(tN, 3.0) * pow(gTot, 3.0) * n * 3.0; // 束流: I ∝ δ³
return col * inten;
}
void main(){
vec2 uv = (gl_FragCoord.xy - 0.5*uRes) / uRes.y;
uv.x *= uAspect;
// 相机光线(FOV≈60°)
vec3 dir = normalize(uCamF + 0.577*uv.x*uCamR + 0.577*uv.y*uCamU);
vec3 x = uCamPos; // 光子位置
vec3 v = dir; // 光子方向(切矢)
vec3 col = vec3(0.0);
bool done = false;
for (int s = 0; s < MAX_STEPS; s++) {
vec3 px = x; // 上一步位置
float r = length(x);
if (r < RS) { col = vec3(0.0); done = true; break; } // 落入视界 -> 阴影
if (r > 90.0) { col = starfield(normalize(v)); done = true; break; } // 逃逸 -> 背景星空
// 零测地线加速度 a = -(3/2) rs h² x / r⁵ 与自适应步长
float h2 = dot(cross(x, v), cross(x, v));
vec3 a = (uLens > 0.5) ? -1.5 * RS * h2 * x / pow(r, 5.0) : vec3(0.0);
float dt = clamp(0.02 + 0.045*r, 0.02, 0.7); // 近黑洞步长更细
v += a * dt; // 半隐式欧辛积分
x += v * dt;
// 穿越吸积盘平面 (y=0):在 px 与 x 之间线性插值
if (uDisk > 0.5 && (px.y * x.y) < 0.0) {
float fr = px.y / (px.y - x.y);
vec3 pHit = mix(px, x, fr);
float rc = length(pHit);
if (rc > DISK_IN && rc < DISK_OUT) { col += diskShade(pHit, v); done = true; break; }
}
}
if (!done) col = starfield(normalize(v));
// 色调映射 + gamma
col = 1.0 - exp(-col * 1.1);
col = pow(col, vec3(0.4545));
gl_FragColor = vec4(col, 1.0);
}
`;
/* ---------------- WebGL 初始化 ---------------- */
function compile(type, src) {
const sh = gl.createShader(type);
gl.shaderSource(sh, src);
gl.compileShader(sh);
if (!gl.getShaderParameter(sh, gl.COMPILE_STATUS)) {
console.error(gl.getShaderInfoLog(sh));
throw new Error('shader compile error');
}
return sh;
}
const prog = gl.createProgram();
gl.attachShader(prog, compile(gl.VERTEX_SHADER, VERT));
gl.attachShader(prog, compile(gl.FRAGMENT_SHADER, FRAG));
gl.linkProgram(prog);
gl.useProgram(prog);
const quad = gl.createBuffer();
gl.bindBuffer(gl.ARRAY_BUFFER, quad);
gl.bufferData(gl.ARRAY_BUFFER, new Float32Array([-1,-1, 3,-1, -1,3]), gl.STATIC_DRAW);
const aPos = gl.getAttribLocation(prog, 'aPos');
gl.enableVertexAttribArray(aPos);
gl.vertexAttribPointer(aPos, 2, gl.FLOAT, false, 0, 0);
const U = {};
['uRes','uCamPos','uCamR','uCamU','uCamF','uTime','uLens','uDoppler','uDisk','uStars','uAspect']
.forEach(n => U[n] = gl.getUniformLocation(prog, n));
/* ---------------- 相机(球面轨道控制) ---------------- */
const cam = { theta: 0.6, phi: 0.10, dist: 16 };
function camBasis() {
const cp = Math.cos(cam.phi), sp = Math.sin(cam.phi);
const ct = Math.cos(cam.theta), st = Math.sin(cam.theta);
const pos = [cam.dist*cp*st, cam.dist*sp, cam.dist*cp*ct];
const f = norm([-pos[0], -pos[1], -pos[2]]);
const r = norm(cross(f, [0,1,0]));
const u = cross(r, f);
return { pos, f, r, u };
}
function norm(v){ const l = Math.hypot(...v)||1; return v.map(x=>x/l); }
function cross(a,b){ return [a[1]*b[2]-a[2]*b[1], a[2]*b[0]-a[0]*b[2], a[0]*b[1]-a[1]*b[0]]; }
let dragging = false, lx = 0, ly = 0;
canvas.addEventListener('mousedown', e => { dragging = true; lx = e.clientX; ly = e.clientY; });
window.addEventListener('mouseup', () => dragging = false);
window.addEventListener('mousemove', e => {
if (!dragging) return;
cam.theta -= (e.clientX - lx) * 0.005;
cam.phi = Math.min(1.5, Math.max(-1.5, cam.phi + (e.clientY - ly) * 0.005));
lx = e.clientX; ly = e.clientY;
});
canvas.addEventListener('wheel', e => {
e.preventDefault();
cam.dist = Math.min(60, Math.max(4, cam.dist * (1 + e.deltaY * 0.001)));
document.getElementById('cam-dist').value = cam.dist;
document.getElementById('cam-dist-val').textContent = cam.dist.toFixed(1) + ' rs';
}, { passive: false });
/* ---------------- 物理量推导(随质量更新) ---------------- */
const C = 299792.458; // km/s
const RS_KM_PER_MSUN = 2.9532; // 每太阳质量的 rs (km)
function currentMass() {
// 滑块 0..100 对数映射 1 .. 1e10 M☉
const t = parseFloat(document.getElementById('mass').value);
return Math.pow(10, t / 10); // 0->1, 100->1e10
}
function fmtKm(km) {
if (km < 1e6) return km.toFixed(1) + ' km';
if (km < 1.496e8) return (km/1e6).toFixed(2) + ' 百万km';
return (km/1.496e8).toFixed(2) + ' AU';
}
function fmtMass(m) {
if (m < 1e4) return m.toFixed(0) + ' M☉';
if (m < 1e8) return (m/1e6).toFixed(1) + '×10⁶ M☉';
return (m/1e9).toFixed(1) + '×10⁹ M☉';
}
function updateDerived() {
const M = currentMass();
const rs = RS_KM_PER_MSUN * M;
const tIsco = 4.55e-4 * M; // 秒 (ISCO 轨道周期近似)
const tIn = 6.3e7 * Math.pow(M/10, -0.25); // 内盘温度量级 K
document.getElementById('mass-val').textContent = fmtMass(M);
document.getElementById('derived').innerHTML =
`<span class="k">史瓦西半径 rs</span><span class="v">${fmtKm(rs)}</span>` +
`<span class="k">光子球 1.5rs</span><span class="v">${fmtKm(rs*1.5)}</span>` +
`<span class="k">ISCO 3rs</span><span class="v">${fmtKm(rs*3)}</span>` +
`<span class="k">阴影直径 ≈5.2rs</span><span class="v">${fmtKm(rs*5.196)}</span>` +
`<span class="k">ISCO 周期</span><span class="v">${tIsco<1? (tIsco*1000).toFixed(2)+' ms' : tIsco<3600? tIsco.toFixed(1)+' s' : (tIsco/3600).toFixed(1)+' h'}</span>` +
`<span class="k">内盘温度(量级)</span><span class="v">${tIn.toExponential(1)} K</span>`;
}
function setMassPreset(m) {
// 反解滑块值 t = 10*log10(m)
document.getElementById('mass').value = Math.round(10 * Math.log10(m));
updateDerived();
}
/* ---------------- 开关 / 视角 / 画质 ---------------- */
const tg = id => document.getElementById(id).checked;
function setView(phi) { cam.phi = phi; }
let quality = 0.75;
document.getElementById('quality').addEventListener('change', e => { quality = parseFloat(e.target.value); resize(); });
document.getElementById('cam-dist').addEventListener('input', e => {
cam.dist = parseFloat(e.target.value);
document.getElementById('cam-dist-val').textContent = cam.dist.toFixed(1) + ' rs';
});
document.getElementById('mass').addEventListener('input', updateDerived);
/* ---------------- 尺寸 ---------------- */
function resize() {
const dpr = Math.min(devicePixelRatio || 1, 2) * quality;
canvas.width = Math.floor(innerWidth * dpr);
canvas.height = Math.floor(innerHeight * dpr);
gl.viewport(0, 0, canvas.width, canvas.height);
}
window.addEventListener('resize', resize);
resize();
/* ---------------- 渲染循环 ---------------- */
function render(t) {
const b = camBasis();
gl.uniform2f(U.uRes, canvas.width, canvas.height);
gl.uniform1f(U.uAspect, canvas.width / canvas.height);
gl.uniform3fv(U.uCamPos, b.pos);
gl.uniform3fv(U.uCamR, b.r);
gl.uniform3fv(U.uCamU, b.u);
gl.uniform3fv(U.uCamF, b.f);
gl.uniform1f(U.uTime, t * 0.001);
gl.uniform1f(U.uLens, tg('tg-lens') ? 1 : 0);
gl.uniform1f(U.uDoppler, tg('tg-doppler') ? 1 : 0);
gl.uniform1f(U.uDisk, tg('tg-disk') ? 1 : 0);
gl.uniform1f(U.uStars, tg('tg-stars') ? 1 : 0);
gl.drawArrays(gl.TRIANGLES, 0, 3);
}
let orbitRAF = null;
function loop(t) {
if (!document.getElementById('view3d').classList.contains('hide')) render(t);
requestAnimationFrame(loop);
}
updateDerived();
requestAnimationFrame(loop);
/* ================= 轨道实验室(2D 史瓦西进动) =================
* 比内方程:d²u/dφ² + u = K + 1.5 rs u², u=1/r, rs=1, K=GM/h²
* 牛顿项 K 给出椭圆,1.5u² 相对论项导致近日点进动 */
const oc = document.getElementById('orbit-canvas');
const octx = oc.getContext('2d');
let orbit = null;
function orbitPreset(kind) {
// 状态 [u, du/dφ]
if (kind === 'isco') {
const r = 3, u = 1/r, K = u - 1.5*u*u; // 圆轨道
orbit = { u, du: 0, K, phi: 0, trail: [], kind };
} else if (kind === 'prec') {
const a = 8, e = 0.6, rp = a*(1-e);
const h2 = 0.5 * a * (1-e*e); // 牛顿 h²=GM a(1-e²), GM=0.5
orbit = { u: 1/rp, du: 0, K: 0.5/h2, phi: 0, trail: [], kind, a, e };
} else {
orbit = { u: 1/4.5, du: 0.16, K: 0.5/2.2, phi: 0, trail: [], kind }; // 坠入
}
document.getElementById('orbit-info').innerHTML = orbitInfoText();
}
function orbitInfoText() {
if (!orbit) return '';
if (orbit.kind === 'prec') {
const dphi = 3*Math.PI / (orbit.a * (1-orbit.e*orbit.e)); // Δφ=6πGM/(c²a(1-e²)), GM/c²=0.5
return `椭圆 a=8rs e=0.6 · 理论进动 <b>Δφ≈${(dphi*180/Math.PI).toFixed(1)}°/圈</b>( exaggerated 强场演示;S2 恒星实测 ~12′/圈)`;
}
if (orbit.kind === 'isco') return `ISCO r=3rs:最内<b>稳定</b>圆轨道,吸积盘内缘。`;
return `角动量不足:粒子越过 ISCO 后<b>螺旋坠入视界</b>。`;
}
function orbitStep() {
const dphi = 0.004;
for (let i = 0; i < 6; i++) {
// RK4
const f = (u, du) => [du, -u + orbit.K + 1.5*u*u];
let [u, du] = [orbit.u, orbit.du];
const k1 = f(u, du), k2 = f(u+k1[0]*dphi/2, du+k1[1]*dphi/2),
k3 = f(u+k2[0]*dphi/2, du+k2[1]*dphi/2), k4 = f(u+k3[0]*dphi, du+k3[1]*dphi);
orbit.u += dphi/6*(k1[0]+2*k2[0]+2*k3[0]+k4[0]);
orbit.du += dphi/6*(k1[1]+2*k2[1]+2*k3[1]+k4[1]);
orbit.phi += dphi;
const r = 1/orbit.u;
if (r < 1 || r > 40) { orbit.dead = true; }
orbit.trail.push([r*Math.cos(orbit.phi), r*Math.sin(orbit.phi)]);
if (orbit.trail.length > 4000) orbit.trail.shift();
}
}
function orbitDraw() {
const W = oc.width, H = oc.height, cx = W/2, cy = H/2;
const scale = (W/2 - 20) / 20; // 视场 20rs
octx.fillStyle = '#050810'; octx.fillRect(0,0,W,H);
const circle = (r, color) => {
octx.strokeStyle = color; octx.beginPath();
octx.arc(cx, cy, r*scale, 0, Math.PI*2); octx.stroke();
};
circle(1, '#4a6cff'); circle(1.5, '#39d0d0'); circle(3, '#ffd479');
if (orbit) {
octx.strokeStyle = '#7fd47f'; octx.beginPath();
orbit.trail.forEach((p, i) => {
const X = cx + p[0]*scale, Y = cy - p[1]*scale;
i ? octx.lineTo(X, Y) : octx.moveTo(X, Y);
});
octx.stroke();
const p = orbit.trail[orbit.trail.length-1];
if (p && !orbit.dead) {
octx.fillStyle = '#fff'; octx.beginPath();
octx.arc(cx + p[0]*scale, cy - p[1]*scale, 4, 0, Math.PI*2); octx.fill();
}
}
}
function orbitLoop() {
if (orbit && !orbit.dead) orbitStep();
if (document.getElementById('orbitlab').classList.contains('show')) orbitDraw();
orbitRAF = requestAnimationFrame(orbitLoop);
}
orbitPreset('prec');
orbitLoop();
/* ---------------- 页签切换 ---------------- */
function switchTab(which) {
const isView = which === 'view';
document.getElementById('tab-view').classList.toggle('active', isView);
document.getElementById('tab-orbit').classList.toggle('active', !isView);
document.getElementById('view3d').classList.toggle('hide', !isView);
document.getElementById('orbitlab').classList.toggle('show', !isView);
}
</script>
</body>
</html>