feat: 新增思想实验室模块与配套功能

- 新增5个物理仿真实验室:黑洞模拟、日地轨道、3D太阳系、π推导、拉格朗日点
- 新增思想实验室后端路由与鉴权逻辑
- 首页新增思想实验室页签与前端渲染逻辑
- 修复中韩半导体监控标的配置错误
- 新增市场技能推送白名单过滤逻辑与单元测试
This commit is contained in:
yangxiangyuan
2026-08-06 13:39:48 +08:00
parent 6a7a2b089a
commit ea379116cc
19 changed files with 2831 additions and 17 deletions
@@ -0,0 +1,34 @@
/**
* 单元测试:market_skills manager 发送侧白名单二次过滤(filterPushRows)
* 运行:node dev_test_scripts/unit/test_manager_filter_push_rows.js(cwd 必须为项目根目录)
*
* 覆盖场景:
* 1. 中韩半导体指:symbol 误配 sh000688 与科创50 撞车(在白名单内),必须被排除名单拦截
* 2. 白名单内 skill(科创50/纳斯达克/COMEX黄金)放行
* 3. 白名单外 skill(COMEX白银 hf_SI)拦截
* 4. 排除名单 skill(巴菲特指)拦截
* 5. 无法识别 symbol 的存量记录默认拦截
* 6. 空数组 / null 不崩溃
*/
const assert = require('assert')
const manager = require('../../src/server/market_skills/manager')
const rows = [
{ skill: '中韩半导体指', title: '涨幅预警' },
{ skill: '科创50', title: '涨幅预警' },
{ skill: '纳斯达克', title: '涨幅预警' },
{ skill: 'COMEX黄金', title: '价格预警' },
{ skill: 'COMEX白银', title: '价格预警' },
{ skill: '巴菲特指', title: '极端高估' },
{ skill: '不存在的skill', title: 'x' }
]
const out = manager.filterPushRows(rows)
const names = out.map(r => r.skill)
assert.deepStrictEqual(names, ['科创50', '纳斯达克', 'COMEX黄金'], '发送侧过滤结果不符:' + JSON.stringify(names))
assert.deepStrictEqual(manager.filterPushRows([]), [])
assert.deepStrictEqual(manager.filterPushRows(null), [])
console.log('PASS test_manager_filter_push_rows')
process.exit(0)
@@ -65,5 +65,24 @@ assert(r4.kept.length === mockQuotes.length, '缺省字段不过滤')
const r5 = applyNotifyAllowlist(null, { notify_symbol_allowlist: ALLOWLIST }) const r5 = applyNotifyAllowlist(null, { notify_symbol_allowlist: ALLOWLIST })
assert(Array.isArray(r5.kept) && r5.kept.length === 0, 'quotes 为 null 时不崩溃') assert(Array.isArray(r5.kept) && r5.kept.length === 0, 'quotes 为 null 时不崩溃')
console.log('== test: manager.isSkillPushAllowed(skill 推送通道白名单) ==')
const { isSkillPushAllowed } = require(path.join(process.cwd(), 'src', 'server', 'market_skills', 'manager.js'))
// 6. 白名单内 skill 放行(8 个保留项)
assert(['sh000001', 'sz399001', 'sh000300', 'sh000688', 'sz399006', 'usINX', 'usIXIC', 'hf_GC'].every(s => isSkillPushAllowed(s, ALLOWLIST)), '白名单内 8 个 symbol 全部放行')
// 7. 白名单外 skill 拦截(截图里仍推送的几个)
assert(!isSkillPushAllowed('usEWY', ALLOWLIST), '韩国综合指数 usEWY 被拦截')
assert(!isSkillPushAllowed('us_buffett_index', ALLOWLIST), '巴菲特指数 us_buffett_index 被拦截')
assert(!isSkillPushAllowed('bj899050', ALLOWLIST), '宝岛加权指数 bj899050 被拦截')
assert(!isSkillPushAllowed('sh601988_299', ALLOWLIST), '中国银行-299 sh601988_299 被拦截')
assert(!isSkillPushAllowed('hf_SI', ALLOWLIST), 'COMEX白银 hf_SI 被拦截')
// 8. 大小写不敏感 / 空名单 / null 名单
assert(isSkillPushAllowed('USINX', ALLOWLIST), '大小写不敏感')
assert(isSkillPushAllowed('usEWY', []), '空名单不限制')
assert(isSkillPushAllowed('usEWY', null), 'null 名单不限制')
console.log(failed === 0 ? '\nALL TESTS PASSED' : `\n${failed} TEST(S) FAILED`) console.log(failed === 0 ? '\nALL TESTS PASSED' : `\n${failed} TEST(S) FAILED`)
process.exit(failed === 0 ? 0 : 1) process.exit(failed === 0 ? 0 : 1)
@@ -0,0 +1,62 @@
// 单元测试:thought_lab 路由鉴权行为
// 运行:node dev_test_scripts/unit/test_thought_lab_api.js
// 断言:
// 1) GET /api/thought_lab/labs 无 cookie -> 200 且返回 5 个 lab(白名单公开元数据)
// 2) lab 页面:无 cookie 无 Referer(直链/外站) -> 401
// 3) lab 页面:无 cookie 但同源 Referer(首页导航进入) -> 200
// 4) lab 页面:带 nav_gate -> 200
const http = require('http')
const path = require('path')
const express = require(path.join(process.cwd(), 'node_modules', 'express'))
const thoughtLab = require(path.join(process.cwd(), 'src', 'server', 'thought_lab'))
const app = express()
thoughtLab.bindRoutes(app)
const server = app.listen(0, async () => {
const port = server.address().port
const get = (p, headers) => new Promise((resolve, reject) => {
const req = http.get({ host: '127.0.0.1', port, path: p, headers: headers || {} }, res => {
let body = ''
res.on('data', c => { body += c })
res.on('end', () => resolve({ status: res.statusCode, body }))
})
req.on('error', reject)
})
let fail = 0
const check = (name, cond, extra) => {
console.log(`${cond ? 'PASS' : 'FAIL'} - ${name}${extra ? ' | ' + extra : ''}`)
if (!cond) fail++
}
try {
const r1 = await get('/api/thought_lab/labs')
let labs = []
try { labs = JSON.parse(r1.body).labs || [] } catch {}
check('labs 列表无 cookie 应 200', r1.status === 200, `status=${r1.status}`)
check('labs 列表应包含 5 个 lab', labs.length === 5, `count=${labs.length}`)
const page = '/tools/thought_lab/labs/blackhole_sim/index.html'
const host = `127.0.0.1:${port}`
const r2 = await get(page)
check('lab 页面直链(无 cookie 无 Referer)应 401', r2.status === 401, `status=${r2.status}`)
const r3 = await get(page, { referer: `http://${host}/`, host })
check('lab 页面同源 Referer(导航进入)应 200', r3.status === 200, `status=${r3.status}`)
const r4 = await get(page, { cookie: 'nav_gate=1', host })
check('lab 页面带 nav_gate 应 200', r4.status === 200, `status=${r4.status}`)
const r5 = await get(page, { referer: 'https://evil.example.com/', host })
check('lab 页面外站 Referer 应 401', r5.status === 401, `status=${r5.status}`)
} catch (e) {
fail++
console.log('FAIL - 请求异常 |', e.message)
}
server.close()
console.log(fail === 0 ? 'ALL PASS' : `${fail} FAILED`)
process.exit(fail === 0 ? 0 : 1)
})
+45 -1
View File
@@ -100,6 +100,7 @@ class ToolboxHome {
this.loadIcons(); this.loadIcons();
this.loadBookmarks(); this.loadBookmarks();
this.loadTools(); this.loadTools();
this.loadThoughtLabs();
this.loadFitnessDiaryReminder(); this.loadFitnessDiaryReminder();
this.loadWeeklyReportReminder(); this.loadWeeklyReportReminder();
} }
@@ -143,6 +144,7 @@ class ToolboxHome {
tabBar.innerHTML = ` tabBar.innerHTML = `
<button class="tab-btn active" data-tab="high" type="button">Yang's 高频 Tools</button> <button class="tab-btn active" data-tab="high" type="button">Yang's 高频 Tools</button>
<button class="tab-btn" data-tab="low" type="button">Yang's 低频 Tools</button> <button class="tab-btn" data-tab="low" type="button">Yang's 低频 Tools</button>
<button class="tab-btn" data-tab="labs" type="button">Yang's 思想实验室</button>
<button class="tab-btn" data-tab="bookmarks" type="button">Yang's 书签导航</button> <button class="tab-btn" data-tab="bookmarks" type="button">Yang's 书签导航</button>
`; `;
@@ -167,6 +169,15 @@ class ToolboxHome {
lowToolGrid.id = 'tool-grid-low'; lowToolGrid.id = 'tool-grid-low';
lowToolsPage.appendChild(lowToolGrid); lowToolsPage.appendChild(lowToolGrid);
// 思想实验室页面(卡片形式与高/低频一致,数据来自 /api/thought_lab/labs)
const labsPage = document.createElement('div');
labsPage.className = 'tab-page';
labsPage.dataset.tab = 'labs';
const labGrid = document.createElement('div');
labGrid.className = 'tool-grid';
labGrid.id = 'tool-grid-labs';
labsPage.appendChild(labGrid);
// 书签导航页面 // 书签导航页面
const bookmarksPage = document.createElement('div'); const bookmarksPage = document.createElement('div');
bookmarksPage.className = 'tab-page'; bookmarksPage.className = 'tab-page';
@@ -271,6 +282,7 @@ class ToolboxHome {
tabPages.appendChild(highToolsPage); tabPages.appendChild(highToolsPage);
tabPages.appendChild(lowToolsPage); tabPages.appendChild(lowToolsPage);
tabPages.appendChild(bookmarksPage); tabPages.appendChild(bookmarksPage);
tabPages.appendChild(labsPage);
// 组装页面 // 组装页面
mainContainer.appendChild(header); mainContainer.appendChild(header);
@@ -362,9 +374,12 @@ class ToolboxHome {
this.tabBar = tabBar; this.tabBar = tabBar;
this.highToolGrid = highToolGrid; this.highToolGrid = highToolGrid;
this.lowToolGrid = lowToolGrid; this.lowToolGrid = lowToolGrid;
this.labGrid = labGrid;
this.toolsPage = highToolsPage; this.toolsPage = highToolsPage;
this.bookmarksPage = bookmarksPage; this.bookmarksPage = bookmarksPage;
this.tabPages = [highToolsPage, lowToolsPage, bookmarksPage]; this.labsPage = labsPage;
this.labTools = [];
this.tabPages = [highToolsPage, lowToolsPage, bookmarksPage, labsPage];
this.bookmarkContainer = bookmarkSections; this.bookmarkContainer = bookmarkSections;
this.bookmarkEmpty = bookmarkEmpty; this.bookmarkEmpty = bookmarkEmpty;
this.bookmarkAdmin = bookmarkAdmin; this.bookmarkAdmin = bookmarkAdmin;
@@ -469,6 +484,35 @@ class ToolboxHome {
renderGrid(this.lowToolGrid, this.lowFreqTools); renderGrid(this.lowToolGrid, this.lowFreqTools);
} }
async loadThoughtLabs() {
try {
const res = await fetch('/api/thought_lab/labs?t=' + Date.now());
if (!res.ok) return;
const data = await res.json();
if (!data || !data.ok) return;
this.labTools = Array.isArray(data.labs) ? data.labs : [];
this.renderLabs();
} catch (err) {
console.error('加载思想实验室列表失败:', err);
}
}
renderLabs() {
if (!this.labGrid) return;
this.labGrid.innerHTML = '';
this.labTools.forEach(lab => {
const card = document.createElement('a');
card.className = 'tool-card';
card.href = lab.href || '#';
if (lab.desc) card.title = lab.desc;
card.innerHTML = `
<div class="tool-icon">${lab.icon || '🧪'}</div>
<div class="tool-text">${lab.name || lab.id || '未命名 lab'}</div>
`;
this.labGrid.appendChild(card);
});
}
openAdmin() { openAdmin() {
this.adminVisible = true; this.adminVisible = true;
if (this.bookmarkAdmin) this.bookmarkAdmin.classList.add('active'); if (this.bookmarkAdmin) this.bookmarkAdmin.classList.add('active');
@@ -0,0 +1,529 @@
<!-- ============================================================
思想实验室 · 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>
@@ -0,0 +1,313 @@
<!-- ============================================================
思想实验室 · labs/lagrange_points · 拉格朗日点
圆形限制性三体问题(CR3BP)交互演示:有效势 / 零速度曲线 / 测试粒子。
独立 lab:单文件自包含,不引用其他 lab 的任何资源。
服务端配置:src/server/thought_lab/labs/lagrange_points/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>
:root { --bg:#0a0e1a; --panel:#101830; --line:rgba(255,255,255,.10); --txt:#d8dee9; --dim:#8fa0bd; --acc:#8fb3ff; --warn:#e8b84a; }
* { margin:0; padding:0; box-sizing:border-box; }
body { background:var(--bg); color:var(--txt); font-family:"PingFang SC","Microsoft YaHei",sans-serif; padding:18px; }
h1 { font-size:21px; color:#fff; }
.sub { color:var(--dim); font-size:12.5px; margin:4px 0 14px; }
.wrap { display:flex; gap:16px; flex-wrap:wrap; align-items:flex-start; }
#stage { position:relative; background:#05070d; border:1px solid var(--line); border-radius:12px; overflow:hidden; }
canvas#cv { display:block; cursor:crosshair; }
#hud { position:absolute; top:10px; left:12px; font-size:12px; line-height:1.7; color:var(--dim);
background:rgba(10,14,26,.62); border:1px solid var(--line); border-radius:8px; padding:8px 12px; pointer-events:none; max-width:340px; }
#hud b { color:#fff; }
.panel { width:430px; background:var(--panel); border:1px solid var(--line); border-radius:12px; padding:16px; }
.panel h2 { font-size:14px; color:var(--acc); margin:14px 0 8px; }
.panel h2:first-child { margin-top:0; }
.panel p, .panel li { font-size:12.8px; line-height:1.75; color:#aeb8cc; }
.panel ul { padding-left:20px; }
.row { display:flex; gap:8px; align-items:center; margin:8px 0; flex-wrap:wrap; }
select, button { background:rgba(143,179,255,.12); border:1px solid rgba(143,179,255,.4); color:#d8e5ff;
padding:6px 10px; border-radius:8px; font-size:12.5px; cursor:pointer; }
button:hover, select:hover { background:rgba(143,179,255,.28); }
input[type=range] { flex:1; accent-color:var(--acc); min-width:140px; }
label { font-size:12.5px; color:var(--dim); display:flex; gap:6px; align-items:center; cursor:pointer; }
code { background:rgba(255,255,255,.08); color:#ffd479; padding:1px 6px; border-radius:4px; font-size:12px; }
table { width:100%; border-collapse:collapse; font-size:12.3px; }
td, th { border:1px solid var(--line); padding:5px 8px; text-align:left; color:#aeb8cc; }
th { color:var(--acc); background:rgba(143,179,255,.08); }
.warn { color:var(--warn); }
.mono { font-family:Consolas,monospace; font-size:12px; }
</style>
</head>
<body>
<h1>拉格朗日点 · 圆形限制性三体问题(CR3BP)交互演示</h1>
<div class="sub">旋转坐标系下的有效势、零速度曲线与测试粒子运动 · 全部物理来自经典力学,无虚构成分 · 点击画面可放置测试粒子</div>
<div class="wrap">
<div id="stage">
<canvas id="cv" width="820" height="700"></canvas>
<div id="hud"></div>
</div>
<div class="panel">
<h2>系统与控制</h2>
<div class="row">
<span style="font-size:12.5px;color:var(--dim)">天体系统</span>
<select id="mu">
<option value="0.01215" selected>地-月 μ=0.01215</option>
<option value="0.0009537">日-木 μ=0.000954</option>
<option value="0.000003003">日-地 μ=3.0e-6</option>
<option value="0.109">冥-卡 μ=0.109(L4/L5 不稳定)</option>
</select>
</div>
<div class="row">
<span style="font-size:12.5px;color:var(--dim)">雅可比常数 C</span>
<input type="range" id="cj" min="2.95" max="4.2" step="0.005" value="3.10">
<span class="mono" id="cj-val">3.100</span>
</div>
<div class="row">
<button id="p-tad4">L4 马蹄/蝌蚪</button>
<button id="p-tad5">L5 蝌蚪</button>
<button id="p-l2">L2 准轨道</button>
<button id="p-trans">L1 颈部穿越</button>
<button id="p-out">外侧偏心轨道</button>
<button id="p-clear">清除粒子</button>
</div>
<div class="row">
<label><input type="checkbox" id="tg-trail" checked> 轨迹拖尾</label>
<label><input type="checkbox" id="tg-forbid" checked> 禁行区/零速度曲线</label>
<span style="font-size:11.5px;color:var(--dim)">(白线=滑块 C 的零速度曲线,暗红=其禁行区;黄线=粒子自身 C₀ 的零速度曲线,粒子永不可越)</span>
</div>
<div class="mono" id="lp-info"></div>
<h2>物理原理(旋转坐标系,归一化 G=M=ω=a=1)</h2>
<ul>
<li>有效势:<code>Ω = ½(x²+y²) + (1−μ)/r₁ + μ/r₂</code>(离心势+双星引力势)</li>
<li>运动方程:<code>ẍ = 2ẏ + Ω/∂x</code>,<code>ÿ = −2ẋ + ∂Ω/∂y</code>(科里奥利力使轨迹弯曲)</li>
<li>唯一守恒量——雅可比常数:<code>C = 2Ω − (ẋ²+ẏ²)</code>。因 v²≥0,<code>2Ω &lt; C</code> 的区域粒子永远无法进入(零速度曲线为边界)</li>
<li>五个平衡点 L1–L5:∇Ω=0。L1–L3 为鞍点(不稳定);L4/L5 虽是极大值点,但当 <code>μ &lt; 0.03852</code>(Routh 判据,1875)时科里奥利力使其稳定</li>
</ul>
<h2>真实世界的拉格朗日点(非虚构)</h2>
<table>
<tr><th>点</th><th>真实驻留者</th></tr>
<tr><td>日-地 L1</td><td>SOHO、ACE、DSCOVR、WIND 太阳观测器</td></tr>
<tr><td>日-地 L2</td><td>JWST、Gaia、Planck、WMAP、Herschel</td></tr>
<tr><td>地-月 L2</td><td>中国"鹊桥"中继星(嫦娥四号,2018);ARTEMIS 探测器曾驻留地-月 L1/L2</td></tr>
<tr><td>日-木 L4/L5</td><td>木星特洛伊小行星群(已确认上万颗)</td></tr>
<tr><td>日-地 L4/L5</td><td>地球特洛伊 2010 TK7、2020 XL5;地-月 L4/L5 的 Kordylewski 尘埃云</td></tr>
<tr><td>L3</td><td>不稳定且被太阳遮挡,无已知驻留天体(科幻"反地球"不存在)</td></tr>
</table>
<h2>诚实声明</h2>
<p class="warn">演示为理想化 CR3BP:忽略其他天体摄动、太阳光压、轨道偏心率。真实任务(如 JWST 的 Lissajous/halo 轨道)需在此模型上叠加摄动与轨道维持。画面中双星在旋转系中固定,这是坐标系选择而非物理近似。</p>
</div>
</div>
<script>
'use strict';
/* ============ 物理内核:CR3BP(归一化单位) ============ */
let MU = 0.01215;
const R1 = (x,y)=>Math.hypot(x+MU, y);
const R2 = (x,y)=>Math.hypot(x-1+MU, y);
// 有效势 Ω 与加速度
function Omega(x,y){ return 0.5*(x*x+y*y) + (1-MU)/R1(x,y) + MU/R2(x,y); }
function acc(x,y,vx,vy){
const r1=R1(x,y), r2=R2(x,y);
const r13=r1*r1*r1, r23=r2*r2*r2;
const ax = 2*vy + x - (1-MU)*(x+MU)/r13 - MU*(x-1+MU)/r23;
const ay = -2*vx + y - (1-MU)*y/r13 - MU*y/r23;
return [ax,ay];
}
function jacobi(x,y,vx,vy){ return 2*Omega(x,y) - (vx*vx+vy*vy); }
// RK4 单步
function rk4(st, dt){
const f = s => { const a = acc(s[0],s[1],s[2],s[3]); return [s[2],s[3],a[0],a[1]]; };
const k1=f(st);
const k2=f(st.map((v,i)=>v+dt/2*k1[i]));
const k3=f(st.map((v,i)=>v+dt/2*k2[i]));
const k4=f(st.map((v,i)=>v+dt*k3[i]));
return st.map((v,i)=>v+dt/6*(k1[i]+2*k2[i]+2*k3[i]+k4[i]));
}
// 共线拉格朗日点:f(x)=∂Ω/x|_{y=0}=0 的三个根
function collinear(){
const f = x => {
const r1=Math.abs(x+MU), r2=Math.abs(x-1+MU);
return x - (1-MU)*(x+MU)/(r1*r1*r1) - MU*(x-1+MU)/(r2*r2*r2);
};
const roots=[];
const zones=[[-1.5,-MU-1e-6],[-MU+1e-6,1-MU-1e-6],[1-MU+1e-6,1.8]];
for(const [a0,b0] of zones){
let a=a0,b=b0,fa=f(a);
for(let i=0;i<200;i++){
const m=(a+b)/2, fm=f(m);
if(fa*fm<=0){ b=m; } else { a=m; fa=fm; }
}
roots.push((a+b)/2);
}
return roots; // [L3, L1, L2]
}
function lagrangePoints(){
const [l3,l1,l2]=collinear();
return { L1:[l1,0], L2:[l2,0], L3:[l3,0], L4:[0.5-MU, Math.sqrt(3)/2], L5:[0.5-MU, -Math.sqrt(3)/2] };
}
/* ============ 视口与势场底图 ============ */
const cv=document.getElementById('cv'), ctx=cv.getContext('2d');
const X0=-1.7, X1=1.9, Y0=-1.527, Y1=1.527;
const GW=410, GH=350; // 势场网格分辨率
const off=document.createElement('canvas'); off.width=GW; off.height=GH;
const octx=off.getContext('2d');
let baseCol=null, valArr=null; // 每像素底色与 2Ω 值
const w2px=(x,y)=>[ (x-X0)/(X1-X0)*cv.width, (1-(y-Y0)/(Y1-Y0))*cv.height ];
const px2w=(px,py)=>[ X0+px/cv.width*(X1-X0), Y0+(1-py/cv.height)*(Y1-Y0) ];
function palette(t){
const stops=[[6,10,34],[16,42,96],[32,96,160],[90,170,200],[240,210,140]];
const s=Math.min(0.999,Math.max(0,t))*(stops.length-1);
const i=Math.floor(s), k=s-i, A=stops[i], B=stops[i+1];
return [A[0]+(B[0]-A[0])*k, A[1]+(B[1]-A[1])*k, A[2]+(B[2]-A[2])*k];
}
function buildBase(){
const img=octx.createImageData(GW,GH);
baseCol=new Float32Array(GW*GH*3); valArr=new Float32Array(GW*GH);
for(let j=0;j<GH;j++){
const y=Y0+(1-j/GH)*(Y1-Y0);
for(let i=0;i<GW;i++){
const x=X0+(i/GW)*(X1-X0);
const v=2*Omega(x,y); valArr[j*GW+i]=v;
const t=Math.pow(Math.min(1,Math.max(0,(v-2.9)/4)),0.5);
const c=palette(t);
baseCol[(j*GW+i)*3]=c[0]; baseCol[(j*GW+i)*3+1]=c[1]; baseCol[(j*GW+i)*3+2]=c[2];
}
}
composite();
}
let CJ=3.10;
let C0p=null; // 当前粒子的雅可比常数(画其专属零速度曲线)
function composite(){
const img=octx.createImageData(GW,GH);
const showF=document.getElementById('tg-forbid').checked;
const mask=new Uint8Array(GW*GH);
for(let k=0;k<GW*GH;k++) mask[k]=valArr[k]<CJ?1:0; // 禁行区: 2Ω<C
const mask0=C0p!=null?new Uint8Array(GW*GH):null;
if(mask0) for(let k=0;k<GW*GH;k++) mask0[k]=valArr[k]<C0p?1:0;
for(let j=0;j<GH;j++)for(let i=0;i<GW;i++){
const k=j*GW+i; let r=baseCol[k*3],g=baseCol[k*3+1],b=baseCol[k*3+2];
if(showF&&mask[k]){ r=r*0.12+34; g=g*0.10+4; b=b*0.10+6; } // 暗红禁行区
// 零速度曲线:分类边界画亮线
if(showF&&(i+1<GW&&mask[k+1]!==mask[k] || j+1<GH&&mask[k+GW]!==mask[k])){ r=235;g=240;b=250; }
// 粒子自身 C0 的零速度曲线(黄线,粒子永不可越)
if(showF&&mask0&&(i+1<GW&&mask0[k+1]!==mask0[k] || j+1<GH&&mask0[k+GW]!==mask0[k])){ r=255;g=214;b=110; }
img.data[k*4]=r; img.data[k*4+1]=g; img.data[k*4+2]=b; img.data[k*4+3]=255;
}
octx.putImageData(img,0,0);
}
/* ============ 粒子与动画 ============ */
let st=null, trail=[], C0=0, pStatus='', pLabel='', hasZVC=false;
function spawn(x,y,vx,vy,label){
st=[x,y,vx,vy]; trail=[]; C0=jacobi(x,y,vx,vy); C0p=C0;
hasZVC = C0 > jacobi(LP.L4[0],LP.L4[1],0,0) + 1e-12; // C0 高于全局最小才有禁行区
pStatus='运行中'; pLabel=label;
composite();
}
const PRESETS={
'p-tad4': ()=>spawn(LP.L4[0], LP.L4[1], 0.025, 0, 'L4 蝌蚪轨道(特洛伊型)'),
'p-tad5': ()=>spawn(LP.L5[0], LP.L5[1], -0.025, 0, 'L5 蝌蚪轨道'),
'p-l2' : ()=>spawn(LP.L2[0]+0.07, 0.05, 0, -0.10, 'L2 准轨道(不稳定,会漂移)'),
'p-trans':()=>spawn(0.72, 0.015, 0, 0, 'L1 颈部穿越(混沌)'),
'p-out' : ()=>spawn(-1.32, 0.0, 0, 0.40, '外侧偏心轨道(旋转系摆线)'),
'p-clear':()=>{ st=null; trail=[]; pStatus=''; pLabel=''; C0p=null; composite(); }
};
let LP=lagrangePoints();
const DT=0.002, SUB=12;
function step(){
if(!st) return;
for(let i=0;i<SUB;i++){
st=rk4(st,DT);
const [x,y]=st;
if(R1(x,y)<0.03){ pStatus='撞入主星'; st=null; break; }
if(R2(x,y)<0.015){ pStatus='撞入伴星'; st=null; break; }
if(Math.abs(x)>4||Math.abs(y)>4){ pStatus='逃逸出系统'; st=null; break; }
}
if(st){ trail.push([st[0],st[1]]); if(trail.length>3600) trail.shift(); }
}
function draw(){
ctx.clearRect(0,0,cv.width,cv.height);
ctx.imageSmoothingEnabled=true;
ctx.drawImage(off,0,0,cv.width,cv.height);
// 双星(旋转系中固定)
const [p1x,p1y]=w2px(-MU,0), [p2x,p2y]=w2px(1-MU,0);
const g1=ctx.createRadialGradient(p1x,p1y,0,p1x,p1y,26);
g1.addColorStop(0,'#fff7d6'); g1.addColorStop(.4,'#ffd75e'); g1.addColorStop(1,'rgba(255,157,60,0)');
ctx.fillStyle=g1; ctx.beginPath(); ctx.arc(p1x,p1y,26,0,7); ctx.fill();
ctx.fillStyle='#ffd75e'; ctx.beginPath(); ctx.arc(p1x,p1y,11,0,7); ctx.fill();
ctx.fillStyle='#c9ccd4'; ctx.beginPath(); ctx.arc(p2x,p2y,5,0,7); ctx.fill();
ctx.fillStyle='#d8dee9'; ctx.font='12px sans-serif';
ctx.fillText(MU>0.05?'冥王星':'地球', p1x-14, p1y+24);
ctx.fillText(MU>0.05?'卡戎':'月球', p2x-10, p2y-10);
// 拉格朗日点标记
ctx.strokeStyle='#fff'; ctx.fillStyle='#fff'; ctx.font='bold 12px sans-serif';
for(const [name,[lx,ly]] of Object.entries(LP)){
const [px,py]=w2px(lx,ly);
ctx.beginPath(); ctx.moveTo(px-5,py); ctx.lineTo(px+5,py); ctx.moveTo(px,py-5); ctx.lineTo(px,py+5); ctx.stroke();
ctx.fillText(name, px+7, py-6);
}
// 拖尾
if(document.getElementById('tg-trail').checked && trail.length>1){
for(let i=1;i<trail.length;i++){
const a=i/trail.length;
ctx.strokeStyle=`rgba(96,200,250,${0.15+0.6*a})`;
ctx.lineWidth=1.4;
const [x1,y1]=w2px(trail[i-1][0],trail[i-1][1]), [x2,y2]=w2px(trail[i][0],trail[i][1]);
ctx.beginPath(); ctx.moveTo(x1,y1); ctx.lineTo(x2,y2); ctx.stroke();
}
}
// 粒子
if(st){
const [px,py]=w2px(st[0],st[1]);
const g=ctx.createRadialGradient(px,py,0,px,py,9);
g.addColorStop(0,'rgba(255,255,255,1)'); g.addColorStop(1,'rgba(120,220,255,0)');
ctx.fillStyle=g; ctx.beginPath(); ctx.arc(px,py,9,0,7); ctx.fill();
}
// HUD
const drift = st? Math.abs(jacobi(st[0],st[1],st[2],st[3])-C0) : 0;
document.getElementById('hud').innerHTML =
`<b>旋转坐标系</b>(双星固定)· μ=${MU}<br>` +
(pLabel? `粒子:${pLabel} · C₀=${C0.toFixed(4)}${hasZVC?'(黄线为其零速度曲线)':'(C₀&lt;C(L4),全平面无禁行区)'}<br>` : '') +
(pStatus? `状态:<b>${pStatus}</b>` + (st? ` · 雅可比漂移 |ΔC|=${drift.toExponential(2)}(RK4 精度自检)`:'') + '<br>' : '') +
`C 滑块=${CJ.toFixed(3)} · 点击画面放置静止粒子`;
step();
requestAnimationFrame(draw);
}
/* ============ 交互 ============ */
document.getElementById('mu').onchange=e=>{ MU=parseFloat(e.target.value); LP=lagrangePoints(); buildBase(); showLP(); PRESETS['p-clear'](); };
document.getElementById('cj').oninput=e=>{ CJ=parseFloat(e.target.value); document.getElementById('cj-val').textContent=CJ.toFixed(3); composite(); };
document.getElementById('tg-forbid').onchange=composite;
for(const id of Object.keys(PRESETS)) document.getElementById(id).onclick=PRESETS[id];
cv.addEventListener('click', e=>{
const r=cv.getBoundingClientRect();
const [x,y]=px2w((e.clientX-r.left)*(cv.width/r.width), (e.clientY-r.top)*(cv.height/r.height));
spawn(x,y,0,0,'自定义(旋转系静止释放)');
});
function showLP(){
const c=n=>jacobi(LP[n][0],LP[n][1],0,0);
document.getElementById('lp-info').innerHTML =
`L1 x=${LP.L1[0].toFixed(4)} (C=${c('L1').toFixed(4)}) · L2 x=${LP.L2[0].toFixed(4)} (C=${c('L2').toFixed(4)})<br>` +
`L3 x=${LP.L3[0].toFixed(4)} (C=${c('L3').toFixed(4)}) · L4/L5 C=${c('L4').toFixed(4)}<br>` +
`Routh 判据 μ&lt;0.03852 → L4/L5 ${MU<0.0385208965?'<b style="color:#7ee2a8">稳定</b>':'<b style="color:#ff8080">不稳定</b>'}`;
}
buildBase(); showLP(); requestAnimationFrame(draw);
</script>
</body>
</html>
@@ -0,0 +1,826 @@
<!-- ============================================================
思想实验室 · labs/pi_derivation · π 推导实验室
随机剖分采样三角形内角,用角度积分公式反推圆周率。
独立 lab:单文件自包含,不引用其他 lab 的任何资源。
服务端配置:src/server/thought_lab/labs/pi_derivation/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>
:root{
--bg:#070a12; --panel:#0d1220; --panel2:#111a2e; --line:#1e2a44;
--cyan:#53d8ff; --green:#42e696; --orange:#ffb454; --pink:#ff6b9d;
--text:#dbe6ff; --dim:#7d8db1;
--mono:'Consolas','Cascadia Mono','JetBrains Mono',monospace;
}
*{margin:0;padding:0;box-sizing:border-box}
body{
background:radial-gradient(1200px 800px at 70% -10%, #101b36 0%, var(--bg) 55%);
color:var(--text); font-family:'Segoe UI','Microsoft YaHei',sans-serif;
min-height:100vh; padding:16px;
}
header{max-width:1440px;margin:0 auto 12px}
h1{font-size:21px;letter-spacing:1px}
h1 b{color:var(--cyan)}
.sub{color:var(--dim);font-size:12.5px;margin-top:4px;line-height:1.6}
main{max-width:1440px;margin:0 auto;display:flex;gap:14px;flex-wrap:wrap}
.panel{
background:linear-gradient(180deg,var(--panel2),var(--panel));
border:1px solid var(--line); border-radius:14px; padding:14px;
box-shadow:0 10px 30px rgba(0,0,0,.35);
}
#geoPanel{flex:1.25;min-width:580px}
#statsPanel{flex:1;min-width:440px}
.ptitle{
font-size:13px;color:var(--cyan);letter-spacing:3px;margin-bottom:10px;
display:flex;justify-content:space-between;align-items:center;
}
.ptitle small{color:var(--dim);letter-spacing:1px}
canvas{display:block;width:100%;border-radius:10px;background:#0a0f1c;border:1px solid var(--line)}
#geo{height:540px}
#hist{height:150px}
#spark{height:72px}
.controls{display:flex;gap:10px;align-items:center;margin-top:10px;flex-wrap:wrap}
button{
background:#16223c;border:1px solid #2b3f66;color:var(--text);
padding:7px 14px;border-radius:8px;cursor:pointer;font-size:13px;
}
button:hover{border-color:var(--cyan);color:var(--cyan)}
.speedbox{display:flex;align-items:center;gap:6px;color:var(--dim);font-size:12px}
input[type=range]{accent-color:var(--cyan);width:110px}
#statusLine{color:var(--dim);font-size:12px;margin-top:8px;font-family:var(--mono)}
/* ---- 统计面板 ---- */
.hero{background:#0a1122;border:1px solid var(--line);border-radius:10px;padding:12px 14px}
.hero .lab{font-size:12px;color:var(--dim);letter-spacing:2px}
.big{font-family:var(--mono);font-size:29px;margin-top:4px;letter-spacing:.5px}
.big .ok{color:var(--green)}
.big .bad{color:#ff5c7a}
.ref{font-family:var(--mono);font-size:13px;color:var(--dim);margin-top:4px}
.meta{display:flex;gap:14px;margin-top:8px;font-size:12px;font-family:var(--mono);flex-wrap:wrap}
.meta b{color:var(--cyan)}
.badge{padding:2px 10px;border-radius:20px;font-size:12px;font-weight:600}
.badge.ok{background:rgba(66,230,150,.15);color:var(--green);border:1px solid rgba(66,230,150,.4)}
.badge.no{background:rgba(255,180,84,.12);color:var(--orange);border:1px solid rgba(255,180,84,.35)}
.formula{
font-family:var(--mono);font-size:12.5px;color:#9fb4de;
background:#0a1122;border-left:3px solid var(--cyan);
padding:8px 12px;border-radius:6px;margin:10px 0;line-height:1.8;
}
.grid4{display:grid;grid-template-columns:repeat(4,1fr);gap:8px;margin-bottom:10px}
.card{background:#0a1122;border:1px solid var(--line);border-radius:8px;padding:8px 10px}
.card .k{font-size:11px;color:var(--dim)}
.card .v{font-family:var(--mono);font-size:17px;margin-top:2px}
.card .s{font-size:11px;color:var(--dim);font-family:var(--mono)}
.acc{color:var(--cyan)} .obt{color:var(--orange)} .grn{color:var(--green)}
.secTitle{font-size:12px;color:var(--dim);letter-spacing:2px;margin:10px 0 6px}
.mcRow{display:flex;gap:10px;align-items:stretch}
.mcRow .card{flex:1}
table.log{width:100%;border-collapse:collapse;font-family:var(--mono);font-size:11.5px;margin-top:6px}
table.log th{color:var(--dim);font-weight:400;text-align:left;padding:3px 6px;border-bottom:1px solid var(--line)}
table.log td{padding:3px 6px;border-bottom:1px solid #141d33;color:#c4d3f5}
.foot{color:#5a6a8f;font-size:11px;margin-top:10px;line-height:1.6}
</style>
</head>
<body>
<header>
<h1>π 推导实验室 <b>Random-Angle Derivation Lab</b></h1>
<div class="sub">
基础流程:正方形 → 随机三角剖分 → 随机取走一块 → 剩余面积守恒重拼矩形(循环)。
每刀切割角度完全随机;记录每个被取走三角形的三个内角,统计锐角/钝角三角形的角度分布,用角度积分公式 π̂ = 3·∫₀<sup>π</sup> θ·ρ(θ)dθ 反推 π。
</div>
</header>
<main>
<!-- ============ 左:几何动画视图 ============ -->
<section class="panel" id="geoPanel">
<div class="ptitle">◤ 几何动画视图 <small>Canvas 实时模拟</small></div>
<canvas id="geo"></canvas>
<div class="controls">
<button id="btnRun">⏸ 暂停</button>
<button id="btnFast">⚡ 后台快进 ×5000</button>
<button id="btnReset">↺ 重置统计</button>
<div class="speedbox">速度 <input type="range" id="speed" min="0.5" max="4" step="0.1" value="1"><span id="speedVal">1.0×</span></div>
</div>
<div id="statusLine">初始化…</div>
</section>
<!-- ============ 右:概率统计计算面板 ============ -->
<section class="panel" id="statsPanel">
<div class="ptitle">◤ 概率统计计算面板 <small>后台实时统计</small></div>
<div class="hero">
<div class="lab">角度积分反推 π̂(主估计器)</div>
<div class="big" id="piHat">—</div>
<div class="ref">理论 π&nbsp;&nbsp;= 3.141592653589793238…</div>
<div class="meta">
<span>绝对误差 <b id="piErr">—</b></span>
<span>稳定小数位 <b id="piDigits">—</b></span>
<span id="badge" class="badge no">目标 ≥ 10 位</span>
</div>
</div>
<div class="formula">
π̂ = (1/N)·Σₖ(αₖ+βₖ+γₖ) = 3·∫₀^π θ·ρ(θ)dθ&nbsp;&nbsp;(ρ 为记录角度的经验分布)<br>
π̂<sub>MC</sub> = 2 / E[sin θ<sub>cut</sub>]&nbsp;&nbsp;(均匀随机测量角,Buffon 型对照)
</div>
<div class="grid4">
<div class="card"><div class="k">取走三角形 N</div><div class="v" id="cN">0</div></div>
<div class="card"><div class="k">角度样本 3N</div><div class="v" id="cAng">0</div></div>
<div class="card"><div class="k acc">锐角三角形</div><div class="v acc" id="cAcute">0</div><div class="s" id="pAcute">P = —</div></div>
<div class="card"><div class="k obt">钝角三角形</div><div class="v obt" id="cObtuse">0</div><div class="s" id="pObtuse">P = —</div></div>
</div>
<div class="secTitle">角度分布直方图(0°–180°,按三角形类型堆叠)</div>
<canvas id="hist"></canvas>
<div class="secTitle">独立蒙特卡洛对照(慢收敛 · 1/√N)</div>
<div class="mcRow">
<div class="card">
<div class="k">π̂<sub>MC</sub> = 2/⟨sinθ⟩</div>
<div class="v grn" id="piBuffon">—</div>
<div class="s">误差 <span id="buffErr">—</span></div>
<div class="s">测量角样本 <span id="buffN">0</span></div>
</div>
<div class="card"><canvas id="spark" style="height:100%;border:none;background:transparent"></canvas></div>
<div class="card">
<div class="k">直方图数值积分 π̂<sub>h</sub>(粗)</div>
<div class="v obt" id="piHistVal">—</div>
<div class="s">分箱 5°,演示离散化误差</div>
</div>
</div>
<div class="secTitle">最近取走样本(每行验证 α+β+γ = π)</div>
<table class="log">
<thead><tr><th>#</th><th>α</th><th>β</th><th>γ</th><th>类型</th><th>Σ (rad)</th></tr></thead>
<tbody id="logBody"></tbody>
</table>
<div class="foot">
原理:欧氏几何中任意三角形内角和恒等于 π。实验室用完全随机的切割反复生成三角形并采样其内角,
以经验分布 ρ(θ) 的积分 3·E[θ] 重现该恒等式——因此主估计器在采样下即确定性地稳定到机器精度(&gt;10 位小数);
右侧 Buffon 型估计器作为对照,展示普通蒙特卡洛只能以 1/√N 速度缓慢收敛。
</div>
</section>
</main>
<script>
'use strict';
/* =========================================================
* π 推导实验室 —— 随机角度统计推导圆周率
* 流程:矩形 → 随机点剖分(切割角随机) → 随机取走一块(记录内角)
* → 剩余块重拼为等面积新矩形 → 循环
* ========================================================= */
// ---------- DOM ----------
const geo = document.getElementById('geo'), g = geo.getContext('2d');
const histCv = document.getElementById('hist'), hc = histCv.getContext('2d');
const sparkCv = document.getElementById('spark'), sc = sparkCv.getContext('2d');
const $ = id => document.getElementById(id);
const el = {
piHat:$('piHat'), err:$('piErr'), digits:$('piDigits'), badge:$('badge'),
n:$('cN'), ang:$('cAng'), acute:$('cAcute'), pacute:$('pAcute'),
obtuse:$('cObtuse'), pobtuse:$('pObtuse'),
p2:$('piBuffon'), p2e:$('buffErr'), cutn:$('buffN'), ph:$('piHistVal'),
log:$('logBody'), status:$('statusLine'),
btnRun:$('btnRun'), btnFast:$('btnFast'), btnReset:$('btnReset'),
speed:$('speed'), speedVal:$('speedVal')
};
// ---------- 常量 ----------
const BINS = 36; // 角度直方图分箱数(0~180°,每箱 5°)
const DUR = {cut:1.15, flash:0.4, remove:1.35, fly:1.2, merge:0.8, settle:0.4, reset:0.85};
const PHASE_CN = {
idle:'等待', cut:'随机切割(切割角完全随机)', flash:'剖分完成',
remove:'随机取走一块 · 记录三个内角', fly:'剩余块重拼 → 新矩形',
merge:'面积守恒合并', settle:'本轮完成', reset:'面积过小 → 重置新正方形'
};
const MONO = "Consolas,'Cascadia Mono',monospace";
// ---------- 工具函数 ----------
const rand = (a,b)=>a+(b-a)*Math.random();
const clamp=(v,a,b)=>v<a?a:(v>b?b:v);
const lerp =(a,b,t)=>a+(b-a)*t;
const easeIO=t=>t<.5?4*t*t*t:1-Math.pow(-2*t+2,3)/2;
const sub=(p,q)=>({x:p.x-q.x,y:p.y-q.y});
const add=(p,q)=>({x:p.x+q.x,y:p.y+q.y});
const mul=(p,s)=>({x:p.x*s,y:p.y*s});
const dot=(p,q)=>p.x*q.x+p.y*q.y;
const len=p=>Math.hypot(p.x,p.y);
const dist=(p,q)=>len(sub(p,q));
const norm=p=>{const l=len(p)||1;return{x:p.x/l,y:p.y/l};};
const rotVec=(v,a)=>({x:v.x*Math.cos(a)-v.y*Math.sin(a), y:v.x*Math.sin(a)+v.y*Math.cos(a)});
const lerpP=(p,q,t)=>({x:lerp(p.x,q.x,t), y:lerp(p.y,q.y,t)});
function areaPoly(p){let a=0;for(let i=0;i<p.length;i++){const q=p[(i+1)%p.length];a+=p[i].x*q.y-q.x*p[i].y;}return Math.abs(a)/2;}
function centroidOf(p){let x=0,y=0;for(const q of p){x+=q.x;y+=q.y;}return{x:x/p.length,y:y/p.length};}
function anglesOf(poly){ // 三角形三个内角(弧度)
const ang=(U,V,Ww)=>{
const v1=sub(V,U), v2=sub(Ww,U);
return Math.acos(clamp(dot(v1,v2)/(len(v1)*len(v2)),-1,1));
};
const [A,B,C]=poly;
return [ang(A,B,C), ang(B,C,A), ang(C,A,B)];
}
function rr(c,x,y,w,h,r){c.beginPath();c.moveTo(x+r,y);c.arcTo(x+w,y,x+w,y+h,r);
c.arcTo(x+w,y+h,x,y+h,r);c.arcTo(x,y+h,x,y,r);c.arcTo(x,y,x+w,y,r);c.closePath();}
// ---------- 画布尺寸 ----------
let W=800,H=540,DPR=1;
function sizeCanvas(cv,ctx,hCss){
const w = cv.clientWidth || cv.parentElement.clientWidth;
const h = hCss || cv.clientHeight;
DPR = window.devicePixelRatio||1;
cv.width = Math.round(w*DPR); cv.height = Math.round(h*DPR);
ctx.setTransform(DPR,0,0,DPR,0,0);
return [w,h];
}
function sizeAll(){
[W,H] = sizeCanvas(geo,g,540);
sizeCanvas(histCv,hc,150);
sizeCanvas(sparkCv,sc,0);
if(S.rect){ S.rect.cx=W/2; S.rect.cy=H*0.47; }
}
// ---------- 全局状态 ----------
const S = {
running:true, speed:1, iter:0,
phase:'idle', t:0,
rect:null, area0:1,
P:null, tris:[], cutSegs:[], remIdx:-1,
remAngles:null, remArea:0, thetaM:0,
nextRect:null, resetNext:false,
flyData:null, RT:null,
recorded:false, recFlash:0,
fast:0
};
// ---------- 后台统计 ----------
const stats = {
nTri:0, sumS:0, // 取走三角形数 / 内角和累加
acute:0, obtuse:0, // 锐角 / 钝角三角形计数
binsA:new Float64Array(BINS), // 锐角三角形的角度直方图
binsO:new Float64Array(BINS), // 钝角三角形的角度直方图
cutN:0, cutSin:0, // 测量切割角样本(Buffon 估计器)
spark:[], sparkTick:1,
log:[]
};
function resetStats(){
stats.nTri=0; stats.sumS=0; stats.acute=0; stats.obtuse=0;
stats.binsA.fill(0); stats.binsO.fill(0);
stats.cutN=0; stats.cutSin=0; stats.spark.length=0; stats.sparkTick=1;
stats.log.length=0;
refreshStats(); drawHist(); drawSpark();
}
// ---------- 几何构建 ----------
function rectCorners(R){
const x=R.cx-R.w/2, y=R.cy-R.h/2;
return [{x,y},{x:x+R.w,y},{x:x+R.w,y:y+R.h},{x,y:y+R.h}];
}
function pieceColor(i){ return `hsla(${(S.iter*47+i*67)%360},66%,58%,0.92)`; }
function buildIteration(){
const R=S.rect, cs=rectCorners(R);
// 随机内点 P → 4 刀切割(P 均匀随机 ⇒ 切割角度完全随机)
const P={x:R.cx+(Math.random()-.5)*R.w*0.6, y:R.cy+(Math.random()-.5)*R.h*0.6};
const tris=[];
for(let i=0;i<4;i++){
tris.push({poly:[cs[i], cs[(i+1)%4], P], color:pieceColor(i)});
}
const segs=cs.map(c=>({a:c,b:P}));
// 以 60% 概率追加随机一刀(把某块再切成两片)
if(Math.random()<0.6){
const i=(Math.random()*tris.length)|0;
const t=tris[i];
const e=t.poly.filter(v=>v!==P); // 外侧边(矩形边)的两个端点
const u=rand(0.15,0.85);
const Q={x:lerp(e[0].x,e[1].x,u), y:lerp(e[0].y,e[1].y,u)};
tris.splice(i,1,
{poly:[e[0],Q,P], color:pieceColor(4)},
{poly:[Q,e[1],P], color:pieceColor(5)});
segs.push({a:P,b:Q});
}
return {P,tris,segs};
}
// ---------- 统计记录 ----------
function recordCore(angs){
const s = angs[0]+angs[1]+angs[2]; // 理论上恒等于 π
stats.nTri++; stats.sumS += s;
const acute = Math.max(angs[0],angs[1],angs[2]) < Math.PI/2;
if(acute) stats.acute++; else stats.obtuse++;
const bins = acute?stats.binsA:stats.binsO;
for(const a of angs){
const k=Math.min(BINS-1, Math.floor(a/Math.PI*BINS));
bins[k]++;
}
stats.log.unshift({n:stats.nTri, d:angs.map(r=>r*180/Math.PI), acute, s});
if(stats.log.length>9) stats.log.pop();
if(--stats.sparkTick<=0){ stats.sparkTick=4; pushSpark(); }
}
function pushSpark(){
if(stats.cutN<8) return;
const p2=2/(stats.cutSin/stats.cutN);
const d=Math.max(0,-Math.log10(Math.abs(p2-Math.PI)/Math.PI));
stats.spark.push(d);
if(stats.spark.length>240) stats.spark.shift();
}
function piBuffon(){ return stats.cutN>=2 ? 2/(stats.cutSin/stats.cutN) : NaN; }
function piHistCoarse(){
let tot=0,sx=0;
for(let k=0;k<BINS;k++){
const c=stats.binsA[k]+stats.binsO[k];
tot+=c; sx+=c*((k+0.5)/BINS*Math.PI);
}
return tot>0 ? 3*sx/tot : NaN;
}
// ---------- 迭代生命周期 ----------
function resetSquare(){
const side=Math.min(W,H)*0.6;
S.rect={cx:W/2, cy:H*0.47, w:side, h:side};
S.area0=side*side;
S.resetNext=false;
}
function startIteration(){
S.iter++;
const R=S.rect;
const b=buildIteration();
S.P=b.P; S.tris=b.tris; S.cutSegs=b.segs;
S.remIdx=(Math.random()*S.tris.length)|0;
// 均匀随机测量角 θ∈[0,π):Buffon 型估计器的独立样本
S.thetaM=Math.random()*Math.PI;
stats.cutN++; stats.cutSin+=Math.sin(S.thetaM);
const rem=S.tris[S.remIdx];
S.remAngles=anglesOf(rem.poly);
S.remArea=areaPoly(rem.poly);
// 新矩形:面积守恒 A' = A − ΔA,长宽比随机
const A2=R.w*R.h-S.remArea;
const asp=rand(0.8,1.5);
S.nextRect={cx:R.cx, cy:R.cy, w:Math.sqrt(A2*asp), h:Math.sqrt(A2/asp)};
S.resetNext = (A2 < 0.15*S.area0) || S.nextRect.w<64 || S.nextRect.h<64;
S.recorded=false;
setPhase('cut');
}
function setPhase(p){
S.phase=p; S.t=0;
if(p==='fly'){
const NR=S.nextRect;
const RT={x:NR.cx-NR.w/2, y:NR.cy-NR.h/2, w:NR.w, h:NR.h};
const pcs=S.tris.filter((_,i)=>i!==S.remIdx);
const slots=layoutSlots(pcs,RT);
S.flyData=pcs.map((pc,i)=>({pc,slot:slots[i]}));
S.RT=RT;
}
}
function nextPhase(){
switch(S.phase){
case 'cut': setPhase('flash'); break;
case 'flash': setPhase('remove'); break;
case 'remove':
if(S.resetNext){ setPhase('reset'); } else { setPhase('fly'); }
break;
case 'fly': setPhase('merge'); break;
case 'merge': setPhase('settle'); break;
case 'settle': S.rect=S.nextRect; S.phase='idle'; break;
case 'reset': resetSquare(); S.phase='idle'; break;
default: S.phase='idle';
}
}
// 快进模式:纯数学迭代(无动画),用于批量采样
function fastIteration(){
const R=S.rect;
const b=buildIteration();
const remI=(Math.random()*b.tris.length)|0;
const rem=b.tris[remI];
recordCore(anglesOf(rem.poly));
const th=Math.random()*Math.PI;
stats.cutN++; stats.cutSin+=Math.sin(th);
const A2=R.w*R.h-areaPoly(rem.poly);
if(A2 < 0.15*S.area0){ resetSquare(); S.iter++; return; }
const asp=rand(0.8,1.5);
const w=Math.sqrt(A2*asp), h=Math.sqrt(A2/asp);
if(w<64||h<64){ resetSquare(); S.iter++; return; }
S.rect={cx:R.cx, cy:R.cy, w, h};
S.iter++;
}
// ---------- 重拼布局:把三角形旋到底边水平,逐行摆入目标矩形 ----------
function layoutSlots(pcs, RT){
const slots=[], pad=8;
const x0=RT.x+pad;
let yBase=RT.y+RT.h-pad, x=x0, rowH=0;
for(const pc of pcs){
const [A,B,C]=pc.poly;
const Vs=[A,B,C];
// 选最长边作为底边
let bi=0, blen=0;
const edges=[[A,B],[B,C],[C,A]];
edges.forEach((e,i)=>{const L=dist(e[0],e[1]); if(L>blen){blen=L;bi=i;}});
const bA=edges[bi][0], bB=edges[bi][1];
const apex=Vs.find(v=>v!==bA&&v!==bB);
const ex=norm(sub(bB,bA));
const hAlt=2*areaPoly(pc.poly)/blen;
const apexProj=dot(sub(apex,bA),ex);
const spanMin=Math.min(0,apexProj), spanMax=Math.max(blen,apexProj);
const span=spanMax-spanMin;
if(x-x0+span>RT.w-2*pad && x>x0){ x=x0; yBase-=rowH+pad; rowH=0; }
// 目标顶点:底边水平、apex 朝上(屏幕 y 向下)
const tA={x:x-spanMin, y:yBase};
const tB={x:x-spanMin+blen, y:yBase};
const tC={x:x-spanMin+apexProj, y:yBase-hAlt};
// 旋转量:把底边转到 +x 方向,并保证 apex 在底边上方
const phi0=Math.atan2(bB.y-bA.y, bB.x-bA.x);
let dRot=-phi0;
if(rotVec(sub(apex,bA),dRot).y>0) dRot+=Math.PI;
const srcC=centroidOf(pc.poly);
const tgtC={x:(tA.x+tB.x+tC.x)/3, y:(tA.y+tB.y+tC.y)/3};
slots.push({dx:tgtC.x-srcC.x, dy:tgtC.y-srcC.y, rot:dRot});
x+=span+pad; rowH=Math.max(rowH,hAlt);
}
return slots;
}
// ---------- 主更新 ----------
function update(dt){
if(S.phase==='idle'){
if(S.fast>0){
const k=Math.min(800,S.fast);
for(let i=0;i<k;i++) fastIteration();
S.fast-=k;
refreshStats(); drawHist(); drawSpark();
}else if(S.running){
startIteration();
}
return;
}
if(!S.running) return;
S.t += dt*S.speed/DUR[S.phase];
if(S.phase==='remove' && !S.recorded && S.t>0.1){
recordCore(S.remAngles);
S.recFlash=1;
}
if(S.t>=1) nextPhase();
}
// ---------- 绘制:几何视图 ----------
function drawGrid(){
g.strokeStyle='rgba(80,120,200,0.06)'; g.lineWidth=1;
g.beginPath();
for(let x=0;x<W;x+=40){g.moveTo(x,0);g.lineTo(x,H);}
for(let y=0;y<H;y+=40){g.moveTo(0,y);g.lineTo(W,y);}
g.stroke();
}
function drawRectSolid(R,alpha,color){
g.save(); g.globalAlpha=alpha;
const x=R.cx-R.w/2, y=R.cy-R.h/2;
g.fillStyle=color||'#1c2a4a';
g.fillRect(x,y,R.w,R.h);
g.strokeStyle='#3f62a8'; g.lineWidth=1.5;
g.strokeRect(x,y,R.w,R.h);
g.fillStyle='rgba(160,190,255,.55)';
g.font='11px '+MONO;
g.fillText(`${R.w|0} × ${R.h|0}`, x, y+R.h+14);
g.restore();
}
function drawPiece(pc,dx,dy,rot,alpha,scale){
const c=pc.centroid||(pc.centroid=centroidOf(pc.poly));
g.save(); g.globalAlpha=alpha;
g.translate(c.x+dx, c.y+dy); g.rotate(rot); if(scale!==1)g.scale(scale,scale);
g.beginPath();
pc.poly.forEach((p,i)=> i?g.lineTo(p.x-c.x,p.y-c.y):g.moveTo(p.x-c.x,p.y-c.y));
g.closePath();
g.fillStyle=pc.color; g.fill();
g.strokeStyle='rgba(5,10,20,.5)'; g.lineWidth=1.5; g.stroke();
g.restore();
}
function collectorPos(){ return {x:W-96,y:36}; }
function drawCollector(){
const c=collectorPos();
g.save();
g.globalAlpha=0.92;
g.fillStyle= S.recFlash>0.05 ? `rgba(83,216,255,${0.12+0.35*S.recFlash})` : 'rgba(83,216,255,0.08)';
rr(g,c.x-84,c.y-22,168,44,8); g.fill();
g.strokeStyle='rgba(83,216,255,0.5)'; g.stroke();
g.fillStyle='#9fdcff'; g.font='12px '+MONO; g.textAlign='center';
g.fillText('∠ 角度记录器', c.x, c.y-5);
g.fillStyle='#e8f6ff';
g.fillText(`已记录 ${stats.nTri} 个三角形`, c.x, c.y+12);
g.textAlign='left';
g.restore();
S.recFlash*=0.93;
}
// 测量切割线:均匀随机角 θ(Buffon 样本的可视化)
function drawMeasureLine(R,P,theta,alpha){
g.save();
const x=R.cx-R.w/2, y=R.cy-R.h/2;
g.beginPath(); g.rect(x,y,R.w,R.h); g.clip();
const d={x:Math.cos(theta),y:Math.sin(theta)};
const L=Math.max(R.w,R.h)*1.2;
g.strokeStyle=`rgba(255,107,157,${alpha})`;
g.setLineDash([7,6]); g.lineWidth=1.4;
g.beginPath();
g.moveTo(P.x-d.x*L,P.y-d.y*L); g.lineTo(P.x+d.x*L,P.y+d.y*L);
g.stroke(); g.setLineDash([]);
g.restore();
g.save();
g.fillStyle=`rgba(255,107,157,${Math.min(1,alpha+0.2)})`;
g.font='11px '+MONO;
g.fillText(`测量切割线 θ = ${(theta*180/Math.PI).toFixed(1)}°(均匀随机)`, x+8, y-8);
g.restore();
}
function drawCutPhase(){
const R=S.rect, t=S.t;
drawRectSolid(R,1);
drawMeasureLine(R,S.P,S.thetaM, 0.25+0.5*t);
// 逐条绘制切割线(进度动画)
const n=S.cutSegs.length;
g.save();
g.strokeStyle='#7ee0ff'; g.lineWidth=2;
g.shadowColor='#53d8ff'; g.shadowBlur=8;
for(let i=0;i<n;i++){
const u=clamp(t*n-i,0,1);
if(u<=0) continue;
const s=S.cutSegs[i], e=lerpP(s.a,s.b,easeIO(u));
g.beginPath(); g.moveTo(s.a.x,s.a.y); g.lineTo(e.x,e.y); g.stroke();
}
g.restore();
if(t>0.15){
g.fillStyle='#fff';
g.beginPath(); g.arc(S.P.x,S.P.y,3.5,0,7); g.fill();
}
}
function drawAngleLabels(tri,alpha){
const Vs=tri.poly, degs=S.remAngles.map(r=>r*180/Math.PI);
g.save(); g.globalAlpha=alpha; g.font='12px '+MONO;
for(let i=0;i<3;i++){
const V=Vs[i], P1=Vs[(i+1)%3], P2=Vs[(i+2)%3];
const bis=norm(add(norm(sub(P1,V)),norm(sub(P2,V))));
g.fillStyle='#ffffff';
g.fillText(degs[i].toFixed(1)+'°', V.x+bis.x*26-14, V.y+bis.y*26+4);
}
g.restore();
}
function drawRemovePhase(){
const t=S.t;
const cp=collectorPos();
S.tris.forEach((pc,i)=>{
if(i===S.remIdx) return;
drawPiece(pc,0,0,0,1,1);
});
const rem=S.tris[S.remIdx];
if(t<0.45){
const pulse=1+0.04*Math.sin(t*40);
drawPiece(rem,0,0,0,1,pulse);
drawAngleLabels(rem, clamp(t/0.12,0,1));
// 高亮描边
const c=centroidOf(rem.poly);
g.save(); g.translate(c.x,c.y); g.scale(pulse,pulse);
g.beginPath();
rem.poly.forEach((p,j)=> j?g.lineTo(p.x-c.x,p.y-c.y):g.moveTo(p.x-c.x,p.y-c.y));
g.closePath(); g.strokeStyle='#ff5c7a'; g.lineWidth=2.5; g.stroke();
g.restore();
}else{
const u=easeIO((t-0.45)/0.55);
const c=centroidOf(rem.poly);
drawPiece(rem,(cp.x-c.x)*u,(cp.y-c.y)*u, u*1.5, 1-u, 1-0.75*u);
// 记录信息浮牌
const d=S.remAngles.map(r=>r*180/Math.PI);
g.save(); g.globalAlpha=clamp((t-0.5)/0.2,0,1);
const txt=`记录 △#${stats.nTri}: ${d[0].toFixed(1)}° + ${d[1].toFixed(1)}° + ${d[2].toFixed(1)}° = π`;
g.font='12px '+MONO;
const tw=g.measureText(txt).width;
g.fillStyle='rgba(10,17,34,.92)';
rr(g, cp.x-tw/2-10, cp.y+30, tw+20, 26, 6); g.fill();
g.strokeStyle='rgba(83,216,255,.5)'; g.stroke();
g.fillStyle='#bfeaff'; g.textAlign='center';
g.fillText(txt, cp.x, cp.y+47);
g.textAlign='left'; g.restore();
}
}
function drawFlyPhase(){
const RT=S.RT, t=S.t;
// 目标矩形虚线轮廓
g.save();
g.strokeStyle='rgba(83,216,255,.7)'; g.setLineDash([6,5]); g.lineWidth=1.5;
g.strokeRect(RT.x,RT.y,RT.w,RT.h); g.setLineDash([]);
g.fillStyle='rgba(83,216,255,.6)'; g.font='11px '+MONO;
g.fillText('目标矩形(面积守恒)', RT.x, RT.y-6);
g.restore();
S.flyData.forEach((fd,j)=>{
const u=clamp(t*1.35-j*0.12,0,1), e=easeIO(u);
drawPiece(fd.pc, fd.slot.dx*e, fd.slot.dy*e, fd.slot.rot*e, 1, 1);
});
}
function drawMergePhase(){
const t=easeIO(S.t);
S.flyData.forEach(fd=>{
drawPiece(fd.pc, fd.slot.dx, fd.slot.dy, fd.slot.rot, 1-t, 1);
});
drawRectSolid(S.nextRect, t);
// 面积守恒浮牌
const A=(S.rect.w*S.rect.h)|0, dA=S.remArea|0, A2=(S.nextRect.w*S.nextRect.h)|0;
g.save(); g.globalAlpha=t;
g.font='12px '+MONO; g.fillStyle='#bfeaff'; g.textAlign='center';
g.fillText(`面积守恒:${A} − ${dA} = ${A2} px² → 新矩形`, S.nextRect.cx, S.nextRect.cy);
g.textAlign='left'; g.restore();
}
function drawResetPhase(){
const t=S.t;
const side=Math.min(W,H)*0.6;
g.save();
g.strokeStyle=`rgba(255,180,84,${1-t})`; g.lineWidth=2;
g.beginPath(); g.arc(W/2,H*0.47, t*Math.max(W,H)*0.6, 0, 7); g.stroke();
g.globalAlpha=easeIO(clamp(t*1.4-0.3,0,1));
const s2=side*(0.6+0.4*easeIO(t));
g.fillStyle='#1c2a4a';
g.fillRect(W/2-s2/2, H*0.47-s2/2, s2, s2);
g.strokeStyle='#3f62a8'; g.strokeRect(W/2-s2/2, H*0.47-s2/2, s2, s2);
g.fillStyle='#ffd9a0'; g.font='13px '+MONO; g.textAlign='center';
g.fillText('面积过小 → 重置为新正方形(统计数据继续累积)', W/2, H*0.47);
g.textAlign='left'; g.restore();
}
function drawHUD(){
g.save();
g.font='12px '+MONO; g.fillStyle='rgba(160,190,255,.8)';
g.fillText(`迭代 #${S.iter}`, 14, 22);
if(S.rect) g.fillText(`矩形 ${S.rect.w|0} × ${S.rect.h|0} px · 面积 ${(S.rect.w*S.rect.h)|0}`, 14, 40);
g.fillStyle='rgba(125,141,177,.8)';
g.fillText(`切割角样本 ${stats.cutN} · 三角形样本 ${stats.nTri}`, 14, 58);
// 阶段说明(底部居中)
const cap=PHASE_CN[S.phase]||'';
g.font='13px "Microsoft YaHei",sans-serif';
const tw=g.measureText(cap).width;
g.fillStyle='rgba(10,17,34,.85)';
rr(g, W/2-tw/2-14, H-38, tw+28, 26, 13); g.fill();
g.strokeStyle='rgba(83,216,255,.3)'; g.stroke();
g.fillStyle='#cfe6ff'; g.textAlign='center';
g.fillText(cap, W/2, H-20);
g.textAlign='left';
g.restore();
}
function draw(){
g.clearRect(0,0,W,H);
drawGrid();
drawCollector();
switch(S.phase){
case 'idle': if(S.rect) drawRectSolid(S.rect,1); break;
case 'cut': drawCutPhase(); break;
case 'flash':
drawMeasureLine(S.rect,S.P,S.thetaM,0.6);
S.tris.forEach(pc=>drawPiece(pc,0,0,0,easeIO(S.t),1));
break;
case 'remove': drawRemovePhase(); break;
case 'fly': drawFlyPhase(); break;
case 'merge': drawMergePhase(); break;
case 'settle': drawRectSolid(S.nextRect,1); break;
case 'reset': drawResetPhase(); break;
}
drawHUD();
}
// ---------- 绘制:直方图 ----------
function drawHist(){
const w=histCv.clientWidth, h=150;
hc.clearRect(0,0,w,h);
let maxCol=1;
for(let k=0;k<BINS;k++) maxCol=Math.max(maxCol, stats.binsA[k]+stats.binsO[k]);
const bw=w/BINS;
for(let k=0;k<BINS;k++){
const a=stats.binsA[k], o=stats.binsO[k];
const hA=(h-22)*a/maxCol, hO=(h-22)*o/maxCol;
hc.fillStyle='rgba(255,180,84,.85)';
hc.fillRect(k*bw+1, h-18-hO, bw-2, hO);
hc.fillStyle='rgba(83,216,255,.85)';
hc.fillRect(k*bw+1, h-18-hO-hA, bw-2, hA);
}
// 90° 分界线
const x90=w*0.5;
hc.strokeStyle='rgba(255,92,122,.7)'; hc.setLineDash([4,4]);
hc.beginPath(); hc.moveTo(x90,4); hc.lineTo(x90,h-18); hc.stroke(); hc.setLineDash([]);
hc.fillStyle='#ff5c7a'; hc.font='10px '+MONO;
hc.fillText('90°', x90+3, 12);
// 坐标轴标签
hc.fillStyle='rgba(125,141,177,.9)';
[[0,'0°'],[0.25,'45°'],[0.5,'90°'],[0.75,'135°'],[1,'180°']].forEach(([f,t])=>{
hc.fillText(t, clamp(w*f-10,2,w-26), h-5);
});
// 图例
hc.fillStyle='rgba(83,216,255,.9)'; hc.fillRect(w-118,6,8,8);
hc.fillStyle='#9fb4de'; hc.fillText('锐角三角形角度', w-106, 13);
hc.fillStyle='rgba(255,180,84,.9)'; hc.fillRect(w-118,19,8,8);
hc.fillStyle='#9fb4de'; hc.fillText('钝角三角形角度', w-106, 26);
}
// ---------- 绘制:蒙特卡洛收敛火花线 ----------
function drawSpark(){
const w=sparkCv.clientWidth, h=sparkCv.clientHeight||72;
sc.clearRect(0,0,w,h);
sc.strokeStyle='rgba(30,42,68,.9)';
for(let i=1;i<=4;i++){
const y=h-(i/5)*h;
sc.beginPath(); sc.moveTo(0,y); sc.lineTo(w,y); sc.stroke();
}
const data=stats.spark;
if(data.length<2){
sc.fillStyle='rgba(125,141,177,.7)'; sc.font='11px '+MONO;
sc.fillText('等待样本累积…', 8, h/2);
return;
}
let maxD=3; for(const d of data) maxD=Math.max(maxD,d);
maxD+=0.8;
sc.beginPath();
data.forEach((d,i)=>{
const x=i/(data.length-1)*w, y=h-(d/maxD)*(h-8)-2;
i?sc.lineTo(x,y):sc.moveTo(x,y);
});
sc.strokeStyle='#42e696'; sc.lineWidth=1.5; sc.stroke();
sc.fillStyle='rgba(125,141,177,.9)'; sc.font='10px '+MONO;
sc.fillText(`MC 正确位数(纵轴) 峰值≈${maxD.toFixed(1)} · 斜率≈-½logN`, 6, 11);
}
// ---------- 统计面板刷新 ----------
function refreshStats(){
const N=stats.nTri;
if(N>0){
const p1=stats.sumS/N; // 主估计器:角度积分
const a=p1.toFixed(13), b=Math.PI.toFixed(13);
let m=0; while(m<a.length && a[m]===b[m]) m++;
const dec=Math.max(0,m-2); // 小数点后稳定位数
el.piHat.innerHTML=
`<span class="ok">${a.slice(0,m)}</span><span class="bad">${a.slice(m)}</span>`;
const err=Math.abs(p1-Math.PI);
el.err.textContent = err===0 ? '0(机器精度内)' : err.toExponential(2);
el.digits.textContent = dec+' 位';
el.badge.className = 'badge '+(dec>=10?'ok':'no');
el.badge.textContent = dec>=10 ? `✓ 已精确 ${dec} 位(≥10 达标)` : `收敛中 · ${dec} 位`;
}else{
el.piHat.textContent='—'; el.err.textContent='—';
el.digits.textContent='—';
el.badge.className='badge no'; el.badge.textContent='目标 ≥ 10 位';
}
el.n.textContent=N;
el.ang.textContent=N*3;
el.acute.textContent=stats.acute;
el.obtuse.textContent=stats.obtuse;
el.pacute.textContent = N?`P = ${(stats.acute/N).toFixed(4)}`:'P = —';
el.pobtuse.textContent = N?`P = ${(stats.obtuse/N).toFixed(4)}`:'P = —';
const p2=piBuffon();
if(isFinite(p2)){
el.p2.textContent=p2.toFixed(10);
el.p2e.textContent=Math.abs(p2-Math.PI).toExponential(2);
}else{ el.p2.textContent='—'; el.p2e.textContent='—'; }
el.cutn.textContent=stats.cutN;
const ph=piHistCoarse();
el.ph.textContent=isFinite(ph)?ph.toFixed(5):'—';
// 最近样本表
el.log.innerHTML=stats.log.map(r=>
`<tr><td>${r.n}</td>`+
r.d.map(x=>`<td>${x.toFixed(2)}°</td>`).join('')+
`<td class="${r.acute?'acc':'obt'}">${r.acute?'锐角':'钝角'}</td>`+
`<td>${r.s.toFixed(13)}</td></tr>`).join('');
// 状态行与按钮
el.status.textContent=
`第 ${S.iter} 轮 · ${PHASE_CN[S.phase]} · 面积 ${(S.rect?S.rect.w*S.rect.h:0)|0} px²`+
(S.fast>0?` · 快进队列 ${S.fast}`:'');
el.btnFast.textContent = `⚡ 后台快进 ×5000${S.fast>0?`(排队 ${S.fast})`:''}`;
}
// ---------- 控件 ----------
el.btnRun.onclick=()=>{
S.running=!S.running;
el.btnRun.textContent=S.running?'⏸ 暂停':'▶ 继续';
};
el.btnFast.onclick=()=>{ S.fast+=5000; };
el.btnReset.onclick=()=>{ resetStats(); };
el.speed.oninput=()=>{
S.speed=parseFloat(el.speed.value);
el.speedVal.textContent=S.speed.toFixed(1)+'×';
};
window.addEventListener('resize',()=>{ sizeAll(); });
// ---------- 主循环 ----------
let last=performance.now(), acc1=0, acc2=0;
function loop(now){
const dt=Math.min(0.05,(now-last)/1000); last=now;
update(dt);
draw();
acc1+=dt; acc2+=dt;
if(acc1>0.12){ acc1=0; refreshStats(); }
if(acc2>0.3){ acc2=0; drawHist(); drawSpark(); }
requestAnimationFrame(loop);
}
// ---------- 启动 ----------
sizeAll();
resetSquare();
refreshStats(); drawHist(); drawSpark();
requestAnimationFrame(loop);
</script>
</body>
</html>
@@ -0,0 +1,428 @@
<!-- ============================================================
思想实验室 · labs/solar_system_3d · 3D 太阳系
基于 NASA JPL J2000 轨道六要素的 3D 太阳系轨道模拟器。
独立 lab:除 Three.js CDN 外不引用其他 lab 的任何资源。
服务端配置:src/server/thought_lab/labs/solar_system_3d/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>3D 太阳系轨道模拟器 - 开普勒真实轨道</title>
<style>
html, body { margin: 0; padding: 0; overflow: hidden; background: #000; height: 100%; }
#canvas-wrap { position: fixed; inset: 0; }
#info-panel {
position: fixed; top: 16px; left: 16px; z-index: 10;
width: 300px; padding: 14px 16px;
background: rgba(8, 12, 24, 0.78);
border: 1px solid rgba(90, 140, 255, 0.35);
border-radius: 10px;
color: #cfe0ff; font: 12px/1.7 "Microsoft YaHei", "PingFang SC", sans-serif;
backdrop-filter: blur(6px);
user-select: none;
}
#info-panel h1 {
margin: 0 0 8px 0; font-size: 16px; color: #fff; letter-spacing: 1px;
}
#info-panel h1 small { font-size: 11px; color: #7fa0e0; font-weight: normal; margin-left: 6px; }
#info-panel .row { display: flex; justify-content: space-between; }
#info-panel .key { color: #8fb0ff; }
#info-panel hr { border: none; border-top: 1px solid rgba(90, 140, 255, 0.25); margin: 8px 0; }
#info-panel .src { color: #7d8db0; font-size: 11px; }
#speed-wrap { margin-top: 6px; }
#speed { width: 100%; accent-color: #5a8cff; cursor: pointer; }
#sim-date { color: #ffd479; font-weight: bold; }
#load-error {
position: fixed; inset: 0; display: none; z-index: 99;
align-items: center; justify-content: center;
color: #ff8080; font: 16px/1.8 "Microsoft YaHei", sans-serif;
background: #000; text-align: center; padding: 20px;
}
</style>
</head>
<body>
<div id="canvas-wrap"></div>
<div id="info-panel">
<h1>3D 太阳系轨道模拟器 <small>Kepler / J2000</small></h1>
<div class="row"><span class="key">左键拖拽</span><span>旋转视角(环绕观察倾斜轨道面)</span></div>
<div class="row"><span class="key">滚轮</span><span>缩放相机远近</span></div>
<div class="row"><span class="key">右键拖拽</span><span>平移视口</span></div>
<hr>
<div id="speed-wrap">
<div class="row"><span class="key">时间流速</span><span><span id="speed-val">60</span> 天/秒</span></div>
<input type="range" id="speed" min="1" max="3650" step="1" value="60">
<div class="row"><span class="key">模拟日期</span><span id="sim-date">2000-01-01</span></div>
</div>
<hr>
<div class="src">
数据来源:NASA JPL J2000 历元轨道六要素<br>
开普勒方程 M = E − e·sinE 牛顿迭代求解<br>
椭圆轨道·太阳位于焦点·近日点快/远日点慢
</div>
</div>
<div id="load-error">Three.js 加载失败。<br>请检查网络(需访问 cdnjs / jsdelivr CDN)后刷新页面。</div>
<!-- 经典全局脚本方式引入,保证 file:// 直接打开可用 -->
<script src="https://cdnjs.cloudflare.com/ajax/libs/three.js/r128/three.min.js"></script>
<script src="https://cdn.jsdelivr.net/npm/three@0.128.0/examples/js/controls/OrbitControls.js"></script>
<script>
'use strict';
// CDN 加载失败保护:避免黑屏无提示
if (typeof THREE === 'undefined' || typeof THREE.OrbitControls === 'undefined') {
document.getElementById('load-error').style.display = 'flex';
throw new Error('Three.js CDN 加载失败');
}
/* =========================================================
* 一、真实天文数据(NASA JPL,J2000 历元近似轨道要素)
* a: 半长轴(AU) e: 偏心率 i: 轨道倾角(°)
* O: 升交点黄经(°) w_bar: 近日点黄经(°) L0: J2000平黄经(°)
* T: 恒星公转周期(天)
* 论证:近点角距 w = w_bar - O ;J2000平近点角 M0 = L0 - w_bar
* =======================================================*/
/* 轨道六要素 + 世纪变化率(secular rates, 每儒略世纪, JPL Standish 1800-2050)
* ad/ed/id/Od/wd/Ld 为 a,e,i,Ω,ϖ,L 对时间(世纪)的导数,用于长期摄动演示
* axisRA/axisDec: IAU 自转轴北极指向(赤道坐标 J2000),用于轴倾角与赤道面
* spinH: 自转周期(小时, 负值=逆行) */
const PLANETS = [
{ name:'水星', a:0.38709927, e:0.20563593, i:7.00497902, O:48.33076593, w_bar:77.45779628, L0:252.25032350,
ad:0.00000037, ed:0.00001906, id:-0.00594749, Od:-0.12534081, wd:0.16047689, Ld:149472.67411175,
axisRA:281.01, axisDec:61.45, spinH:1407.6, color:0x9c9c9c, r:0.38, type:'rock' },
{ name:'金星', a:0.72333566, e:0.00677672, i:3.39467605, O:76.67984255, w_bar:131.60246718, L0:181.97909950,
ad:0.00000390, ed:-0.00004107, id:-0.00078890, Od:-0.27769418, wd:0.00268329, Ld:58517.81538729,
axisRA:272.76, axisDec:67.16, spinH:-5832.5, color:0xe6c98a, r:0.60, type:'rock' },
{ name:'地球', a:1.00000261, e:0.01671123, i:0.00001531, O:0.0, w_bar:102.93768193, L0:100.46457166,
ad:0.00000562, ed:-0.00004392, id:-0.01294668, Od:0.0, wd:0.32255575, Ld:35999.37244981,
axisRA:0.0, axisDec:90.0, spinH:23.93, color:0x4f94cd, r:0.62, type:'rock' },
{ name:'火星', a:1.52371034, e:0.09339410, i:1.84969142, O:49.55953891, w_bar:-23.94362959, L0:-4.55343205,
ad:0.00001847, ed:0.00007882, id:-0.00813131, Od:-0.29257343, wd:-0.44441088, Ld:19140.30268499,
axisRA:317.68, axisDec:52.89, spinH:24.62, color:0xd1603d, r:0.48, type:'rock' },
{ name:'木星', a:5.20288700, e:0.04838624, i:1.30439695, O:100.47390909, w_bar:14.72847983, L0:34.39644051,
ad:-0.00011607, ed:-0.00013253, id:-0.00183714, Od:0.20469106, wd:0.21252668, Ld:3034.90371757,
axisRA:268.05, axisDec:64.49, spinH:9.93, color:0xd8a56a, r:1.70, type:'gas' },
{ name:'土星', a:9.53667594, e:0.05386179, i:2.48599187, O:113.66242448, w_bar:92.59887831, L0:49.95424423,
ad:-0.00125060, ed:-0.00050524, id:0.00193609, Od:-0.28867794, wd:-0.41897216, Ld:1222.11494028,
axisRA:40.59, axisDec:83.54, spinH:10.66, color:0xe3c986, r:1.45, ring:true, type:'gas' },
{ name:'天王星', a:19.18916464, e:0.04725744, i:0.77263783, O:74.01692503, w_bar:170.95427630, L0:313.23810451,
ad:-0.00196176, ed:-0.00004397, id:-0.00242939, Od:0.04240589, wd:0.40805281, Ld:428.49512596,
axisRA:257.31, axisDec:-15.18, spinH:-17.24, color:0x7fd4d4, r:1.00, type:'gas' },
{ name:'海王星', a:30.06992276, e:0.00859048, i:1.77004347, O:131.78422574, w_bar:44.96476227, L0:-55.12002969,
ad:0.00026291, ed:0.00005105, id:0.00035372, Od:-0.00508664, wd:-0.32241464, Ld:218.48620044,
axisRA:299.36, axisDec:43.46, spinH:16.11, color:0x4666e0, r:0.98, type:'gas' },
{ name:'冥王星·矮行星', a:39.48211, e:0.24882730, i:17.14001206, O:110.30393684, w_bar:224.06891629, L0:238.92903833,
ad:0, ed:0, id:0, Od:0, wd:0, Ld:145.2,
axisRA:132.99, axisDec:-6.16, spinH:-153.3, color:0xbfa77f, r:0.30, type:'rock' }
];
const AU = 12; // 场景比例:1 AU = 12 个 Three.js 单位(仅缩放显示,几何形状不变)
const DEG = Math.PI / 180;
// J2000.0 历元。注意:严格定义是 TT 2000-01-01 12:00,与 UTC 相差约 64 秒。
// 本科普模拟忽略 TT-UTC 时差(视觉影响 << 像素级);高精度版本需处理 ΔT=TT-UTC。
const J2000_MS = Date.UTC(2000, 0, 1, 12, 0, 0);
const OBLIQ = 23.4392911 * DEG; // 黄赤交角,用于赤道->黄道转换
/* IAU 北极(赤道坐标 RA/Dec) -> 场景坐标单位矢量(自转轴方向) */
function axisToScene(ra, dec) {
const r = ra * DEG, d = dec * DEG;
const xq = Math.cos(d) * Math.cos(r), yq = Math.cos(d) * Math.sin(r), zq = Math.sin(d);
// 赤道 -> 黄道
const xe = xq;
const ye = Math.cos(OBLIQ) * yq + Math.sin(OBLIQ) * zq;
const ze = -Math.sin(OBLIQ) * yq + Math.cos(OBLIQ) * zq;
// 黄道 -> 场景 (X=x, Y=z, Z=-y)
return new THREE.Vector3(xe, ze, -ye).normalize();
}
/* 程序化行星表面纹理(使自转可见):gas=纬向条带, rock=噪斑 */
function shade(hex, f) {
const r = (hex >> 16) & 255, g = (hex >> 8) & 255, b = hex & 255;
return `rgb(${Math.min(255, r * f) | 0},${Math.min(255, g * f) | 0},${Math.min(255, b * f) | 0})`;
}
function makePlanetTexture(p) {
const c = document.createElement('canvas');
c.width = 128; c.height = 64;
const g = c.getContext('2d');
g.fillStyle = shade(p.color, 1); g.fillRect(0, 0, 128, 64);
if (p.type === 'gas') {
for (let y = 0; y < 64; y += 4) { // 纬向条带
g.fillStyle = shade(p.color, 0.8 + 0.4 * Math.abs(Math.sin(y * 0.5)));
g.fillRect(0, y, 128, 3);
}
} else {
for (let i = 0; i < 60; i++) { // 噪斑
g.fillStyle = shade(p.color, 0.7 + Math.random() * 0.6);
g.beginPath();
g.arc(Math.random() * 128, Math.random() * 64, 2 + Math.random() * 5, 0, 7);
g.fill();
}
}
const t = new THREE.CanvasTexture(c);
return t;
}
/* 给定世纪数 T,返回该时刻的轨道要素(含长期摄动)与旋转分量、平近点角 */
function elementsAt(p, T) {
const a = p.a + p.ad * T;
const e = p.e + p.ed * T;
const i = (p.i + p.id * T) * DEG;
const O = (p.O + p.Od * T) * DEG;
const wb = (p.w_bar + p.wd * T) * DEG;
const L = (p.L0 + p.Ld * T) * DEG;
const w = wb - O; // 近点角距
const M = L - wb; // 平近点角
return {
a, e, M,
cosw: Math.cos(w), sinw: Math.sin(w),
cosO: Math.cos(O), sinO: Math.sin(O),
cosi: Math.cos(i), sini: Math.sin(i)
};
}
/* =========================================================
* 二、开普勒方程求解(独立封装,牛顿迭代法)
* M = E - e*sinE => 求偏近点角 E
* =======================================================*/
function solveKepler(M, e, tol = 1e-8, maxIter = 64) {
// 归一化 M 到 [-π, π],提升收敛性
let Mn = M % (2 * Math.PI);
if (Mn > Math.PI) Mn -= 2 * Math.PI;
if (Mn < -Math.PI) Mn += 2 * Math.PI;
// 初值:低偏心率用 M,高偏心率用 π
let E = (e < 0.8) ? Mn : Math.PI;
for (let k = 0; k < maxIter; k++) {
const f = E - e * Math.sin(E) - Mn;
const fp = 1 - e * Math.cos(E);
const dE = f / fp;
E -= dE;
if (Math.abs(dE) < tol) break;
}
return E;
}
/* 由偏近点角 E 计算黄道三维坐标(AU),太阳位于椭圆焦点
* 轨道面内坐标(焦点在原点):
* x' = a(cosE - e) y' = a*sqrt(1-e^2)*sinE
* 再经 Rz(Ω)·Rx(i)·Rz(ω) 旋转到黄道系 */
function orbitalPositionAU(el, E) {
const xp = el.a * (Math.cos(E) - el.e);
const yp = el.a * Math.sqrt(1 - el.e * el.e) * Math.sin(E);
const { cosw, sinw, cosO, sinO, cosi, sini } = el;
const x = (cosO * cosw - sinO * sinw * cosi) * xp + (-cosO * sinw - sinO * cosw * cosi) * yp;
const y = (sinO * cosw + cosO * sinw * cosi) * xp + (-sinO * sinw + cosO * cosw * cosi) * yp;
const z = (sinw * sini) * xp + (cosw * sini) * yp;
return [x, y, z];
}
// 黄道坐标 -> Three.js 场景坐标(黄道面为 XZ 平面,Y 轴朝北黄极)
function toScene(xyz) {
return new THREE.Vector3(xyz[0] * AU, xyz[2] * AU, -xyz[1] * AU);
}
/* =========================================================
* 三、场景搭建
* =======================================================*/
const scene = new THREE.Scene();
scene.background = new THREE.Color(0x000000);
const camera = new THREE.PerspectiveCamera(60, innerWidth / innerHeight, 0.1, 30000);
camera.position.set(0, 220, 420);
const renderer = new THREE.WebGLRenderer({ antialias: true });
renderer.setPixelRatio(Math.min(devicePixelRatio, 2));
renderer.setSize(innerWidth, innerHeight);
document.getElementById('canvas-wrap').appendChild(renderer.domElement);
// OrbitControls:左键旋转 / 滚轮缩放 / 右键平移
const controls = new THREE.OrbitControls(camera, renderer.domElement);
controls.enableDamping = true;
controls.dampingFactor = 0.06;
controls.minDistance = 6;
controls.maxDistance = 4000;
// 光照:太阳点光源 + 微弱环境光
scene.add(new THREE.AmbientLight(0x333344, 0.6));
const sunLight = new THREE.PointLight(0xffffff, 1.6, 0, 2);
scene.add(sunLight);
// ---- 太阳(坐标原点)----
const sun = new THREE.Mesh(
new THREE.SphereGeometry(3.2, 48, 48),
new THREE.MeshBasicMaterial({ color: 0xffcc33 })
);
scene.add(sun);
// 太阳辉光 sprite
(function addSunGlow() {
const c = document.createElement('canvas');
c.width = c.height = 256;
const g = c.getContext('2d');
const grad = g.createRadialGradient(128, 128, 0, 128, 128, 128);
grad.addColorStop(0, 'rgba(255,220,120,1)');
grad.addColorStop(0.25, 'rgba(255,170,60,0.45)');
grad.addColorStop(1, 'rgba(255,140,0,0)');
g.fillStyle = grad;
g.fillRect(0, 0, 256, 256);
const sp = new THREE.Sprite(new THREE.SpriteMaterial({
map: new THREE.CanvasTexture(c),
blending: THREE.AdditiveBlending, depthWrite: false, transparent: true
}));
sp.scale.set(22, 22, 1);
scene.add(sp);
})();
// ---- 稀疏星空点云 ----
(function addStarfield() {
const N = 2200;
const pos = new Float32Array(N * 3);
for (let i = 0; i < N; i++) {
// 均匀球面分布,半径 6000~9000
const r = 6000 + Math.random() * 3000;
const th = Math.random() * Math.PI * 2;
const ph = Math.acos(2 * Math.random() - 1);
pos[i * 3] = r * Math.sin(ph) * Math.cos(th);
pos[i * 3 + 1] = r * Math.cos(ph);
pos[i * 3 + 2] = r * Math.sin(ph) * Math.sin(th);
}
const geo = new THREE.BufferGeometry();
geo.setAttribute('position', new THREE.BufferAttribute(pos, 3));
scene.add(new THREE.Points(geo, new THREE.PointsMaterial({
color: 0xffffff, size: 1.6, sizeAttenuation: false,
transparent: true, opacity: 0.75, depthWrite: false
})));
})();
// ---- 文字标签 sprite ----
function makeLabel(text) {
const c = document.createElement('canvas');
c.width = 128; c.height = 64;
const g = c.getContext('2d');
g.font = 'bold 30px "Microsoft YaHei", sans-serif';
g.textAlign = 'center';
g.textBaseline = 'middle';
g.fillStyle = 'rgba(220,235,255,0.95)';
g.fillText(text, 64, 32);
const sp = new THREE.Sprite(new THREE.SpriteMaterial({
map: new THREE.CanvasTexture(c),
transparent: true, depthTest: false
}));
sp.scale.set(9, 4.5, 1);
sp.renderOrder = 5;
return sp;
}
// ---- 行星 + 各自倾斜的 3D 轨道线 ----
const ORBIT_SEG = 256;
PLANETS.forEach(p => {
p.group = new THREE.Group(); // 轨道位置
p.axisGroup = new THREE.Group(); // 自转轴倾角(本地 +Y = 自转轴)
// 由 IAU 北极指向计算轴四元数,严格绑定真实轴倾角
p.axisGroup.quaternion.setFromUnitVectors(new THREE.Vector3(0, 1, 0), axisToScene(p.axisRA, p.axisDec));
p.group.add(p.axisGroup);
// 行星球体(视觉放大 + 程序化纹理使自转可见)
p.mesh = new THREE.Mesh(
new THREE.SphereGeometry(p.r, 32, 32),
new THREE.MeshStandardMaterial({ map: makePlanetTexture(p), roughness: 0.85, metalness: 0.05 })
);
p.axisGroup.add(p.mesh);
// 土星环:位于本地 XZ 面 = 垂直自转轴 = 土星赤道面(不再硬编码角度)
if (p.ring) {
const ring = new THREE.Mesh(
new THREE.RingGeometry(p.r * 1.4, p.r * 2.3, 64),
new THREE.MeshBasicMaterial({ color: 0xcbb98a, side: THREE.DoubleSide, transparent: true, opacity: 0.55 })
);
ring.rotation.x = Math.PI / 2;
p.axisGroup.add(ring);
}
// 名称标签(sprite 永远面向相机,距离缩放见 animate)
p.label = makeLabel(p.name);
p.label.position.set(0, p.r + 2.6, 0);
p.group.add(p.label);
scene.add(p.group);
// 轨道线:动态 BufferGeometry,每帧随长期摄动更新
p.orbitPos = new Float32Array((ORBIT_SEG + 1) * 3);
p.orbitGeom = new THREE.BufferGeometry();
p.orbitGeom.setAttribute('position', new THREE.BufferAttribute(p.orbitPos, 3).setUsage(THREE.DynamicDrawUsage));
const orbitLine = new THREE.Line(
p.orbitGeom,
new THREE.LineBasicMaterial({ color: 0x66aaff, transparent: true, opacity: 0.32 })
);
scene.add(orbitLine);
p.spinAngle = 0;
});
/* =========================================================
* 四、时间演化与动画循环
* 平近点角 M(t) = M0 + n*t (t 为自 J2000 起的模拟天数)
* 牛顿迭代求 E -> 真位置。近日点附近 dν/dt 自然变大,
* 自动满足开普勒第二定律(面积速度恒定)。
* =======================================================*/
let simDays = 0; // 自 J2000 历元起的模拟天数
let daysPerSecond = 60; // 时间流速
const speedInput = document.getElementById('speed');
const speedVal = document.getElementById('speed-val');
const simDateEl = document.getElementById('sim-date');
speedInput.addEventListener('input', () => {
daysPerSecond = parseFloat(speedInput.value);
speedVal.textContent = daysPerSecond;
});
const clock = new THREE.Clock();
function animate() {
requestAnimationFrame(animate);
const dt = Math.min(clock.getDelta(), 0.1);
const dDays = dt * daysPerSecond;
simDays += dDays;
const T = simDays / 36525; // 自 J2000 起的儒略世纪数(长期摄动自变量)
for (const p of PLANETS) {
const el = elementsAt(p, T); // 含世纪变化率的当前要素
const E = solveKepler(el.M, el.e);
p.group.position.copy(toScene(orbitalPositionAU(el, E)));
// 轨道线随摄动实时更新
for (let s = 0; s <= ORBIT_SEG; s++) {
const Ec = (s / ORBIT_SEG) * Math.PI * 2;
const v = toScene(orbitalPositionAU(el, Ec));
p.orbitPos[s * 3] = v.x; p.orbitPos[s * 3 + 1] = v.y; p.orbitPos[s * 3 + 2] = v.z;
}
p.orbitGeom.attributes.position.needsUpdate = true;
// 自转:角度 = 2π * (24/自转周期h) * 天数(负周期=逆行)
p.spinAngle += 2 * Math.PI * (24 / p.spinH) * dDays;
p.mesh.rotation.y = p.spinAngle;
// 标签距离缩放:视大小近似恒定,拉远不再过大
const dist = camera.position.distanceTo(p.group.position);
p.label.scale.set(dist * 0.02, dist * 0.01, 1);
}
// 模拟日期显示
const d = new Date(J2000_MS + simDays * 86400000);
simDateEl.textContent = d.toISOString().slice(0, 10);
controls.update();
renderer.render(scene, camera);
}
animate();
// 窗口自适应
window.addEventListener('resize', () => {
camera.aspect = innerWidth / innerHeight;
camera.updateProjectionMatrix();
renderer.setSize(innerWidth, innerHeight);
});
</script>
</body>
</html>
@@ -0,0 +1,248 @@
<!-- ============================================================
思想实验室 · labs/sun_earth_orbit · 日地轨道
太阳-地球运行轨迹 2D 动画,演示开普勒第二定律。
独立 lab:单文件自包含,不引用其他 lab 的任何资源。
服务端配置:src/server/thought_lab/labs/sun_earth_orbit/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>
* { margin: 0; padding: 0; }
html, body { width: 100%; height: 100%; background: #05070d; overflow: hidden; }
canvas { display: block; width: 100%; height: 100%; }
#hud {
position: absolute; top: 12px; left: 14px;
color: #8fa0bd; font: 12px/1.7 "PingFang SC", "Microsoft YaHei", sans-serif;
background: rgba(10,14,26,0.55); padding: 8px 12px; border-radius: 8px;
border: 1px solid rgba(255,255,255,0.08); pointer-events: none;
}
#controls {
position: absolute; bottom: 14px; left: 50%; transform: translateX(-50%);
display: flex; gap: 10px; align-items: center;
background: rgba(10,14,26,0.7); padding: 10px 16px; border-radius: 12px;
border: 1px solid rgba(255,255,255,0.10);
font: 13px "PingFang SC", "Microsoft YaHei", sans-serif; color: #c5cfe0;
}
#controls button {
background: rgba(143,179,255,0.15); border: 1px solid rgba(143,179,255,0.4);
color: #d8e5ff; padding: 6px 14px; border-radius: 8px; cursor: pointer; font-size: 13px;
}
#controls button:hover { background: rgba(143,179,255,0.3); }
#controls input[type=range] { width: 110px; accent-color: #8fb3ff; }
label { display: flex; align-items: center; gap: 6px; cursor: pointer; user-select: none; }
</style>
</head>
<body>
<canvas id="cv"></canvas>
<div id="hud">
地球公转 · 开普勒第二定律(近日点快,远日点慢)<br>
<span id="stat"></span><br>
<span style="color:#e8b84a">⚠ 演示动画:轨道偏心率放大至 e=0.35(真实 0.0167),非真实扁率</span>
</div>
<div id="controls">
<button id="btnPause">暂停</button>
<span>速度</span><input type="range" id="speed" min="0.2" max="5" step="0.1" value="1">
<label><input type="checkbox" id="showOrbit" checked> 轨道线</label>
<label><input type="checkbox" id="showTrail" checked> 拖尾</label>
</div>
<script>
const cv = document.getElementById('cv');
const ctx = cv.getContext('2d');
let W = 0, H = 0, cx = 0, cy = 0;
function resize() {
// iframe 内 clientWidth 可能在脚本执行时为 0,优先用视口尺寸
const w = window.innerWidth || document.documentElement.clientWidth || 800;
const h = window.innerHeight || document.documentElement.clientHeight || 600;
W = cv.width = Math.max(1, Math.floor(w * devicePixelRatio));
H = cv.height = Math.max(1, Math.floor(h * devicePixelRatio));
cx = W / 2; cy = H / 2;
ctx.setTransform(1, 0, 0, 1, 0, 0);
buildStars();
}
window.addEventListener('resize', resize);
if (window.ResizeObserver) {
new ResizeObserver(resize).observe(document.documentElement);
}
// ---- 星空背景 ----
let stars = [];
function buildStars() {
stars = [];
const n = Math.floor(W * H / 9000);
for (let i = 0; i < n; i++) {
stars.push({
x: Math.random() * W, y: Math.random() * H,
r: Math.random() * 1.2 * devicePixelRatio + 0.3,
a: Math.random() * 0.6 + 0.15,
tw: Math.random() * 2 + 1
});
}
}
// ---- 轨道参数(椭圆,e 夸张化便于观察)----
const ecc = 0.35; // 离心率(真实 0.017,夸张展示)
let baseA = 0; // 半长轴,resize 时计算
let sunR = 0, earthR = 0, moonOrbit = 0, moonR = 0;
function layout() {
baseA = Math.min(W, H) * 0.36;
sunR = Math.min(W, H) * 0.055;
earthR = sunR * 0.32;
moonOrbit = earthR * 2.6;
moonR = earthR * 0.28;
}
// ---- 开普勒运动:用真近点角近似,近日点加速 ----
let M = 0; // 平近点角
function keplerPos(theta) {
// r = a(1-e²)/(1+e·cosθ)
const r = baseA * (1 - ecc * ecc) / (1 + ecc * Math.cos(theta));
return { r, x: r * Math.cos(theta), y: r * Math.sin(theta) };
}
function nextTheta(theta) {
// 角速度 ∝ 1/r²(角动量守恒 → 开普勒第二定律)
const r = baseA * (1 - ecc * ecc) / (1 + ecc * Math.cos(theta));
const dTheta = 0.010 * Math.pow(baseA / r, 2);
return theta + dTheta;
}
let theta = Math.PI; // 从近日点出发
let moonAng = 0;
let paused = false;
let speedMul = 1;
const trail = [];
const TRAIL_MAX = 260;
// ---- 绘制 ----
function draw(t) {
ctx.setTransform(1, 0, 0, 1, 0, 0);
// 深空底色
ctx.fillStyle = '#05070d';
ctx.fillRect(0, 0, W, H);
// 星空(闪烁)
for (const s of stars) {
const tw = 0.6 + 0.4 * Math.sin(t * 0.001 * s.tw + s.x);
ctx.globalAlpha = s.a * tw;
ctx.fillStyle = '#cdd8ee';
ctx.beginPath();
ctx.arc(s.x, s.y, s.r, 0, 7);
ctx.fill();
}
ctx.globalAlpha = 1;
layout();
// 太阳焦点偏移:椭圆焦点在原点,太阳在焦点上,轨道中心偏移 a·e
const ocx = cx + baseA * ecc; // 轨道几何中心
const ocy = cy;
// 轨道线
if (document.getElementById('showOrbit').checked) {
ctx.strokeStyle = 'rgba(140,160,200,0.30)';
ctx.lineWidth = 1 * devicePixelRatio;
ctx.setLineDash([6 * devicePixelRatio, 6 * devicePixelRatio]);
ctx.beginPath();
ctx.ellipse(ocx, ocy, baseA, baseA * Math.sqrt(1 - ecc * ecc), 0, 0, 7);
ctx.stroke();
ctx.setLineDash([]);
}
// 位置(太阳位于原点=焦点 cx,cy)
const p = keplerPos(theta);
const ex = cx + p.x, ey = cy + p.y;
// 拖尾
if (document.getElementById('showTrail').checked) {
trail.push({ x: ex, y: ey });
if (trail.length > TRAIL_MAX) trail.shift();
for (let i = 1; i < trail.length; i++) {
const k = i / trail.length;
ctx.strokeStyle = `rgba(96,165,250,${k * 0.55})`;
ctx.lineWidth = 1.6 * k * devicePixelRatio;
ctx.beginPath();
ctx.moveTo(trail[i - 1].x, trail[i - 1].y);
ctx.lineTo(trail[i].x, trail[i].y);
ctx.stroke();
}
} else trail.length = 0;
// 太阳(光晕)
const glow = ctx.createRadialGradient(cx, cy, 0, cx, cy, sunR * 4);
glow.addColorStop(0, 'rgba(255,200,80,0.9)');
glow.addColorStop(0.25, 'rgba(255,160,50,0.35)');
glow.addColorStop(1, 'rgba(255,140,40,0)');
ctx.fillStyle = glow;
ctx.beginPath();
ctx.arc(cx, cy, sunR * 4, 0, 7);
ctx.fill();
const sunBody = ctx.createRadialGradient(cx - sunR*0.3, cy - sunR*0.3, sunR*0.1, cx, cy, sunR);
sunBody.addColorStop(0, '#fff7d6');
sunBody.addColorStop(0.6, '#ffd75e');
sunBody.addColorStop(1, '#ff9d3c');
ctx.fillStyle = sunBody;
ctx.beginPath();
ctx.arc(cx, cy, sunR, 0, 7);
ctx.fill();
// 地球
const eg = ctx.createRadialGradient(ex - earthR*0.35, ey - earthR*0.35, earthR*0.1, ex, ey, earthR);
eg.addColorStop(0, '#9ed2ff');
eg.addColorStop(0.55, '#3b82d9');
eg.addColorStop(1, '#1b4a8f');
ctx.fillStyle = eg;
ctx.beginPath();
ctx.arc(ex, ey, earthR, 0, 7);
ctx.fill();
// 月球
const mx = ex + moonOrbit * Math.cos(moonAng);
const my = ey + moonOrbit * Math.sin(moonAng);
ctx.fillStyle = '#c9ccd4';
ctx.beginPath();
ctx.arc(mx, my, moonR, 0, 7);
ctx.fill();
// 月球轨道淡线
ctx.strokeStyle = 'rgba(200,205,215,0.15)';
ctx.lineWidth = devicePixelRatio * 0.7;
ctx.beginPath();
ctx.arc(ex, ey, moonOrbit, 0, 7);
ctx.stroke();
// 状态(防御除零)
const rNow = p.r;
const peri = baseA * (1 - ecc), apo = baseA * (1 + ecc);
const span = apo - peri;
const phase = span > 0 ? (rNow - peri) / span : 0.5; // 0=近日点 1=远日点
// 真实日地距离:按同一真近点角、用真实偏心率 0.0167 计算(地球轨道 a = 1 AU)
const eccReal = 0.0167;
const rRealAU = (1 - eccReal * eccReal) / (1 + eccReal * Math.cos(theta));
document.getElementById('stat').textContent =
'真实日地距离: ' + rRealAU.toFixed(3) + ' AU(近日点0.983 / 远日点1.017) · ' +
(phase < 0.25 ? '接近近日点(快)' : phase > 0.75 ? '接近远日点(慢)' : '中途');
// 推进
if (!paused) {
const steps = Math.max(1, Math.round(speedMul * 2));
for (let i = 0; i < steps; i++) { theta = nextTheta(theta); }
moonAng += 0.045 * speedMul;
}
requestAnimationFrame(draw);
}
document.getElementById('btnPause').onclick = function () {
paused = !paused;
this.textContent = paused ? '继续' : '暂停';
};
document.getElementById('speed').oninput = function () {
speedMul = parseFloat(this.value);
};
resize();
requestAnimationFrame(draw);
</script>
</body>
</html>
+11
View File
@@ -83,6 +83,7 @@ const boxBackup = require('./box_backup')
const yuanzhupai = require('./yuanzhupai') const yuanzhupai = require('./yuanzhupai')
const shortLink = require('./short_link') const shortLink = require('./short_link')
const dataGateway = require('./data_gateway') const dataGateway = require('./data_gateway')
const thoughtLab = require('./thought_lab')
const ossFileCabinet = require('./oss_file_cabinet') const ossFileCabinet = require('./oss_file_cabinet')
yuanzhupai.initDb() yuanzhupai.initDb()
@@ -6495,6 +6496,16 @@ app.use('/tools/investment_ledger', (req, res, next) => {
} catch { return res.status(401).send('未授权') } } catch { return res.status(401).send('未授权') }
}) })
// ============================================================
// 思想实验室:纯导航鉴权(nav_gate)+ lab 静态资源 + lab 列表 API
// 必须注册在下方全局 express.static 之前,否则静态资源会绕过鉴权中间件
// ============================================================
try {
thoughtLab.bindRoutes(app)
} catch (e) {
try { logJSON('thought_lab.bind_routes.error', { error: String(e.message || e) }, 'thought_lab') } catch {}
}
app.get(['/package.json', '/package-lock.json', '/pnpm-lock.yaml', '/yarn.lock', '/robots.txt'], (_req, res) => { app.get(['/package.json', '/package-lock.json', '/pnpm-lock.yaml', '/yarn.lock', '/robots.txt'], (_req, res) => {
res.status(404).send('Not Found') res.status(404).send('Not Found')
}) })
@@ -9,10 +9,10 @@
"display": true, "display": true,
"schedule_comment": "刷新频率 (", "schedule_comment": "刷新频率 (",
"schedule": "10m", "schedule": "10m",
"symbol_comment": "监控的代", "symbol_comment": "监控的代(中韩半导体ETF华泰柏瑞,跟踪中证中韩半导体指数;原误配 sh000688 科创50 已于 2026-08-06 修正)",
"symbol": "sh000688", "symbol": "sh513310",
"url_comment": "点击卡片右上角房子图标跳转的地址", "url_comment": "点击卡片右上角房子图标跳转的地址",
"url": "https://gu.qq.com/sh000688", "url": "https://gu.qq.com/sh513310",
"source_comment": "数据来源", "source_comment": "数据来源",
"source": "tencent", "source": "tencent",
"alerts_comment": "预警阈值配", "alerts_comment": "预警阈值配",
@@ -69,11 +69,15 @@
} }
], ],
"market_status": { "market_status": {
"timezone": "Asia/Seoul", "timezone": "Asia/Shanghai",
"sessions": [ "sessions": [
{ {
"start": "09:00", "start": "09:30",
"end": "15:30" "end": "11:30"
},
{
"start": "13:00",
"end": "15:00"
} }
], ],
"display_label": "中韩半导" "display_label": "中韩半导"
+81 -9
View File
@@ -583,12 +583,14 @@ const handleChinaRegular = async ({ skillId, skillName, config, alerts, policy,
logJSON('skills.cn.summary.skip.closed_day', { skill: skillName, date: dateStr }, 'market_skills') logJSON('skills.cn.summary.skip.closed_day', { skill: skillName, date: dateStr }, 'market_skills')
return return
} }
const rows = buildAlertRows(skillName, alerts, dataTime) const rows = filterPushRows(buildAlertRows(skillName, alerts, dataTime))
if (rows.length === 0) return
addSummaryRows(dateStr, rows, false) addSummaryRows(dateStr, rows, false)
const { slot, hhmm } = getChinaSlotAndTime(policy, parts) const { slot, hhmm } = getChinaSlotAndTime(policy, parts)
const key = `skills.bucket.cn.${dateStr}.${slot}` const key = `skills.bucket.cn.${dateStr}.${slot}`
const prev = readStateJson(key, []) const prev = readStateJson(key, [])
const merged = dedupeByAlert([...(Array.isArray(prev) ? prev : []), ...rows]) // 发送侧再过滤:清掉桶里修复前存入的存量记录
const merged = filterPushRows(dedupeByAlert([...(Array.isArray(prev) ? prev : []), ...rows]))
writeStateJson(key, merged) writeStateJson(key, merged)
const lines = makeBucketLines(merged) const lines = makeBucketLines(merged)
if (lines.length === 0) return if (lines.length === 0) return
@@ -599,7 +601,8 @@ const handleChinaRegular = async ({ skillId, skillName, config, alerts, policy,
} }
const handleNonChinaRegular = async ({ skillId, skillName, config, alerts, policy, dateStr, dataTime }) => { const handleNonChinaRegular = async ({ skillId, skillName, config, alerts, policy, dateStr, dataTime }) => {
const rows = buildAlertRows(skillName, alerts, dataTime) const rows = filterPushRows(buildAlertRows(skillName, alerts, dataTime))
if (rows.length === 0) return
addSummaryRows(dateStr, rows, false) addSummaryRows(dateStr, rows, false)
const dayState = getDayState(dateStr) const dayState = getDayState(dateStr)
const sid = safeSkillId(skillId) const sid = safeSkillId(skillId)
@@ -618,11 +621,12 @@ const handleNonChinaRegular = async ({ skillId, skillName, config, alerts, polic
} }
const key = `skills.bucket.non_cn.${dateStr}.${sid}` const key = `skills.bucket.non_cn.${dateStr}.${sid}`
const prev = readStateJson(key, []) const prev = readStateJson(key, [])
let merged = dedupeByAlert([...(Array.isArray(prev) ? prev : []), ...rows]) // 发送侧再过滤:清掉桶里和延迟队列里修复前存入的存量记录(跨天顺延的旧记录也在此清除)
let merged = filterPushRows(dedupeByAlert([...(Array.isArray(prev) ? prev : []), ...rows]))
if (dayState.nonChinaFirstSent && Number(dayState.nonChinaTotal || 0) === 1) { if (dayState.nonChinaFirstSent && Number(dayState.nonChinaTotal || 0) === 1) {
const deferred = getDeferredList(dateStr) const deferred = getDeferredList(dateStr)
if (Array.isArray(deferred) && deferred.length > 0) { if (Array.isArray(deferred) && deferred.length > 0) {
merged = dedupeByAlert([...deferred, ...merged]) merged = filterPushRows(dedupeByAlert([...deferred, ...merged]))
setDeferredList(dateStr, []) setDeferredList(dateStr, [])
} }
} }
@@ -668,8 +672,9 @@ const maybeSendDailySummary = async ({ skillName, config, policy, dateStr, parts
const cut = parseHHMM(policy.dailySummaryTime) const cut = parseHHMM(policy.dailySummaryTime)
if (parts.hour < cut.hour || (parts.hour === cut.hour && parts.minute < cut.minute)) return if (parts.hour < cut.hour || (parts.hour === cut.hour && parts.minute < cut.minute)) return
const pool = getSummaryPool(dateStr) const pool = getSummaryPool(dateStr)
const regular = dedupeByAlert(pool.regular || []) // 发送侧再过滤:汇总池里修复前存入的存量记录不发送
const urgent = dedupeByAlert(pool.urgent || []) const regular = filterPushRows(dedupeByAlert(pool.regular || []))
const urgent = filterPushRows(dedupeByAlert(pool.urgent || []))
if (regular.length === 0 && urgent.length === 0) { if (regular.length === 0 && urgent.length === 0) {
dayState.dailySummarySent = true dayState.dailySummarySent = true
setDayState(dateStr, dayState) setDayState(dateStr, dayState)
@@ -693,17 +698,82 @@ const maybeSendDailySummary = async ({ skillName, config, policy, dateStr, parts
setDayState(dateStr, dayState) setDayState(dateStr, dayState)
} }
// 只展示不推送的 skill(按 config.name 精确匹配)
// 中韩半导体指:config.symbol 误配为 sh000688(与科创50 撞车,会被白名单放行),故用排除名单强制停推
const EXCLUDED_FROM_PUSH_SKILLS = new Set([ const EXCLUDED_FROM_PUSH_SKILLS = new Set([
'中国银行-299法', '中国银行-299法',
'中国神华-299法', '中国神华-299法',
'巴菲特指数', '巴菲特指数',
'美国战争指数', '美国战争指数',
'流动性状态机' '流动性状态机',
'中韩半导体指'
]) ])
// 推送告警白名单(与 markets.js 共用 config/markets.json 的 notify_symbol_allowlist 配置)
// 仅 symbol 在白名单中的 skill 参与邮件/日历/周报推送,其余卡片只展示不推送
const MARKETS_CONFIG_FILE = path.join(process.cwd(), 'config', 'markets.json')
let notifyAllowlistCache = { mtime: 0, list: null }
const getNotifyAllowlist = () => {
try {
const mtime = fs.statSync(MARKETS_CONFIG_FILE).mtimeMs
if (notifyAllowlistCache.mtime === mtime) return notifyAllowlistCache.list
const cfg = JSON.parse(fs.readFileSync(MARKETS_CONFIG_FILE, 'utf-8'))
const list = Array.isArray(cfg.notify_symbol_allowlist)
? cfg.notify_symbol_allowlist.map(s => String(s || '').trim().toLowerCase()).filter(Boolean)
: null
notifyAllowlistCache = { mtime, list }
return list
} catch {
return null // 配置缺失/损坏时不限制(兼容旧行为)
}
}
// 白名单判定:list 为 null 或空数组 = 不限制;否则 symbol 必须在白名单内(双方均小写化,大小写不敏感)
const isSkillPushAllowed = (symbol, list) => {
if (!Array.isArray(list) || list.length === 0) return true
const sym = String(symbol || '').trim().toLowerCase()
return list.some(s => String(s || '').trim().toLowerCase() === sym)
}
// skillName → symbol 映射(读磁盘 config,保证发送侧也能识别历史存量记录)
const getSkillNameSymbolMap = () => {
const map = new Map()
try {
if (!fs.existsSync(SKILLS_DIR)) return map
for (const dirent of fs.readdirSync(SKILLS_DIR, { withFileTypes: true })) {
if (!dirent.isDirectory()) continue
try {
const cfg = JSON.parse(fs.readFileSync(path.join(SKILLS_DIR, dirent.name, 'config.json'), 'utf-8'))
if (cfg && cfg.name) map.set(String(cfg.name), String(cfg.symbol || ''))
} catch {}
}
} catch {}
return map
}
// 发送侧白名单过滤:入口过滤只挡新告警,桶/延迟队列/汇总池里的存量记录在发送前再清一遍
// 无法识别 symbol 的记录默认拦截(白名单语义 = 只保留指定指数)
const filterPushRows = rows => {
if (!Array.isArray(rows) || rows.length === 0) return []
const list = getNotifyAllowlist()
if (!Array.isArray(list) || list.length === 0) return rows
const nameSymbol = getSkillNameSymbolMap()
return rows.filter(r => {
const nm = String(r.skill || '')
if (EXCLUDED_FROM_PUSH_SKILLS.has(nm)) return false
const sym = nameSymbol.has(nm) ? nameSymbol.get(nm) : (r && r.symbol != null ? String(r.symbol) : '')
return isSkillPushAllowed(sym, list)
})
}
const sendAlerts = async (skillId, skillName, config, rawAlerts, dataRows) => { const sendAlerts = async (skillId, skillName, config, rawAlerts, dataRows) => {
if (!rawAlerts || rawAlerts.length === 0) return if (!rawAlerts || rawAlerts.length === 0) return
if (EXCLUDED_FROM_PUSH_SKILLS.has(skillName)) return if (EXCLUDED_FROM_PUSH_SKILLS.has(skillName)) return
// 推送白名单:symbol 不在白名单的 skill 不进入邮件/日历/周报推送(也不进入日终汇总池)
if (!isSkillPushAllowed(config && config.symbol, getNotifyAllowlist())) {
logJSON('skills.notify.allowlist.skip', { skill: skillName, symbol: config && config.symbol }, 'market_skills')
return
}
const p = bjParts() const p = bjParts()
const dateStr = p.dateStr const dateStr = p.dateStr
const policy = getPolicy(config) const policy = getPolicy(config)
@@ -875,5 +945,7 @@ module.exports = {
} }
logJSON('skills.manager.started', { count: skills.size }, 'market_skills') logJSON('skills.manager.started', { count: skills.size }, 'market_skills')
}, },
getLatestData getLatestData,
isSkillPushAllowed,
filterPushRows
} }
+149
View File
@@ -0,0 +1,149 @@
// ============================================================
// thought_lab/index.js - 思想实验室路由模块
// 职责:
// - /tools/thought_lab/labs/<lab_id>/ 实验页面与静态资源(仅导航鉴权 nav_gate)
// - GET /api/thought_lab/labs 返回已启用的 lab 列表(首页页签渲染用)
//
// 目录约定(参照 data_gateway 的 skills 分类思想):
// - 每个 lab 是 labs/ 下一个完全独立的子文件夹,互不干扰、互不继承
// - 后端侧 labs/<lab_id>/ 存放该 lab 的 config.json;
// 未来若该 lab 需要数据库、OSS、定时任务等后台逻辑,也落在该文件夹内
// - 前端页面对应 public/tools/thought_lab/labs/<lab_id>/index.html
// ============================================================
const fs = require('fs')
const path = require('path')
const express = require('express')
const LABS_DIR = path.join(__dirname, 'labs')
const PUBLIC_DIR = path.join(process.cwd(), 'public', 'tools', 'thought_lab')
const FLAGS_PATH = path.join(process.cwd(), 'config', 'flags.json')
const NAV_AUTH_COOKIE = 'nav_gate'
// 全局导航鉴权开关(与首页 / 其他工具同一来源 config/flags.json -> navAuth.enable_auth)
// 开关关闭时全站放开,本模块同步放开,保持与容器行为一致
const isNavAuthEnabled = () => {
try {
const f = JSON.parse(fs.readFileSync(FLAGS_PATH, 'utf-8'))
const cfg = (f && f.navAuth) || {}
return cfg.enable_auth !== false
} catch { return true }
}
// ------------------------------------------------------------
// 鉴权:仅导航鉴权(nav_gate cookie),无自定义 gate / GUID / token
// ------------------------------------------------------------
const parseCookie = (raw) => {
const out = {}
try {
String(raw || '').split(';').forEach(item => {
const idx = item.indexOf('=')
if (idx < 1) return
const key = item.slice(0, idx).trim()
const val = item.slice(idx + 1).trim()
if (key) out[key] = val
})
} catch {}
return out
}
const hasNavAuth = (req) => {
try {
const cookies = parseCookie(req.headers.cookie || '')
return cookies[NAV_AUTH_COOKIE] === '1'
} catch { return false }
}
// 同源 Referer 判定:从首页导航点击/模态框进入 lab 页面时携带同源 Referer
const hasSameOriginReferer = (req) => {
try {
const referer = String(req.headers.referer || '')
const host = String(req.headers.host || '')
if (!referer || !host) return false
const u = new URL(referer)
return u.host === host
} catch { return false }
}
const navAuthPage = (req, res, next) => {
if (!isNavAuthEnabled()) return next()
if (hasNavAuth(req)) return next()
// 导航鉴权语义:允许从首页导航进入(同源 Referer),直链/外站访问仍拒绝
if (hasSameOriginReferer(req)) return next()
return res.status(401).send('未授权')
}
const navAuthApi = (req, res, next) => {
if (!isNavAuthEnabled()) return next()
if (hasNavAuth(req)) return next()
return res.status(401).json({ ok: false, error: 'unauthorized' })
}
// ------------------------------------------------------------
// lab 配置读取
// ------------------------------------------------------------
const loadLabConfig = (labId) => {
try {
const cfgPath = path.join(LABS_DIR, labId, 'config.json')
if (!fs.existsSync(cfgPath)) return null
const cfg = JSON.parse(fs.readFileSync(cfgPath, 'utf-8'))
if (!cfg || typeof cfg !== 'object') return null
return cfg
} catch {
return null
}
}
// 枚举所有已启用的 lab,按 sort_order 升序(同序按 id 字典序)
const listLabs = () => {
const result = []
try {
if (!fs.existsSync(LABS_DIR)) return result
const dirs = fs.readdirSync(LABS_DIR, { withFileTypes: true })
for (const d of dirs) {
if (!d.isDirectory()) continue
const cfg = loadLabConfig(d.name)
if (!cfg || cfg.enabled === false) continue
const id = String(cfg.id || d.name)
result.push({
id,
name: String(cfg.name || id),
icon: String(cfg.icon || '🧪'),
desc: String(cfg.desc || ''),
sort_order: Number(cfg.sort_order || 0),
href: `/tools/thought_lab/labs/${d.name}/index.html`
})
}
} catch {}
result.sort((a, b) => {
if (a.sort_order !== b.sort_order) return a.sort_order - b.sort_order
return String(a.id).localeCompare(String(b.id))
})
return result
}
// ------------------------------------------------------------
// 路由注册
// 顺序:静态鉴权中间件必须注册在 express.static 之前(Rule 12.4)
// ------------------------------------------------------------
const bindRoutes = (app) => {
// 1) /tools/thought_lab 全部子路径:仅导航鉴权
app.use('/tools/thought_lab', navAuthPage)
// 2) 静态资源:public/tools/thought_lab/labs/<lab_id>/...
app.use('/tools/thought_lab', express.static(PUBLIC_DIR))
// 3) 白名单公开接口:lab 列表(仅名称/图标/简介等元数据,供首页页签渲染,
// 与 /api/tools 同级;按 Rule 12.8 在 API 守卫之前显式注册)
app.get('/api/thought_lab/labs', (req, res) => {
try {
return res.json({ ok: true, labs: listLabs() })
} catch (e) {
return res.status(500).json({ ok: false, error: 'internal_error' })
}
})
// 4) 其余 /api/thought_lab 接口:默认导航鉴权
app.use('/api/thought_lab', navAuthApi)
}
module.exports = { bindRoutes, listLabs, loadLabConfig }
@@ -0,0 +1,15 @@
{
"_comment": "思想实验室 · 黑洞模拟:基于史瓦西度规零测地线数值积分的广义相对论光线追踪教学模拟器,可观察引力透镜、黑洞阴影、光子环、多普勒束流的吸积盘,并附 2D 轨道实验室演示近日点进动。本 lab 完全独立;未来若需要数据库、OSS 等后台逻辑,统一放在本文件夹内。",
"id_comment": "lab 唯一标识(snake_case),与文件夹名保持一致",
"id": "blackhole_sim",
"name_comment": "首页卡片显示名称",
"name": "黑洞模拟",
"icon_comment": "首页卡片图标(emoji)",
"icon": "🕳️",
"desc_comment": "一句话简介,作为卡片 tooltip 展示",
"desc": "广义相对论光线追踪 · 引力透镜 / 吸积盘 / 轨道进动",
"sort_order_comment": "卡片排序,数值越小越靠前",
"sort_order": 10,
"enabled_comment": "是否启用(false 时列表接口不返回该 lab)",
"enabled": true
}
@@ -0,0 +1,15 @@
{
"_comment": "思想实验室 · 拉格朗日点:圆形限制性三体问题(CR3BP)交互演示,展示旋转坐标系下的有效势、零速度曲线与禁行区,可切换地-月/日-木/日-地/冥-卡系统并点击放置测试粒子,观察 L4/L5 蝌蚪轨道、L2 准轨道等真实轨道形态。本 lab 完全独立;未来若需要数据库、OSS 等后台逻辑,统一放在本文件夹内。",
"id_comment": "lab 唯一标识(snake_case),与文件夹名保持一致",
"id": "lagrange_points",
"name_comment": "首页卡片显示名称",
"name": "拉格朗日点",
"icon_comment": "首页卡片图标(emoji)",
"icon": "⚖️",
"desc_comment": "一句话简介,作为卡片 tooltip 展示",
"desc": "限制性三体问题 · 有效势 / 零速度曲线 / 特洛伊轨道",
"sort_order_comment": "卡片排序,数值越小越靠前",
"sort_order": 20,
"enabled_comment": "是否启用(false 时列表接口不返回该 lab)",
"enabled": true
}
@@ -0,0 +1,15 @@
{
"_comment": "思想实验室 · π 推导实验室:通过「正方形 → 随机三角剖分 → 随机取走一块 → 面积守恒重拼矩形」的循环,记录每个被取走三角形的三个内角,用角度积分公式 π̂ = 3·E[θ] 反推圆周率,并附 Buffon 型蒙特卡洛对照展示 1/√N 收敛速度。本 lab 完全独立;未来若需要数据库、OSS 等后台逻辑,统一放在本文件夹内。",
"id_comment": "lab 唯一标识(snake_case),与文件夹名保持一致",
"id": "pi_derivation",
"name_comment": "首页卡片显示名称",
"name": "π 推导实验室",
"icon_comment": "首页卡片图标(emoji)",
"icon": "π",
"desc_comment": "一句话简介,作为卡片 tooltip 展示",
"desc": "随机剖分采样三角形内角 · 角度积分反推圆周率",
"sort_order_comment": "卡片排序,数值越小越靠前",
"sort_order": 50,
"enabled_comment": "是否启用(false 时列表接口不返回该 lab)",
"enabled": true
}
@@ -0,0 +1,15 @@
{
"_comment": "思想实验室 · 3D 太阳系:基于 NASA JPL J2000 历元轨道六要素(含世纪变化率)的 3D 太阳系轨道模拟器,用牛顿迭代解开普勒方程推演真实行星位置,支持时间流速调节、行星自转与轴倾角还原。本 lab 完全独立;未来若需要数据库、OSS 等后台逻辑,统一放在本文件夹内。",
"id_comment": "lab 唯一标识(snake_case),与文件夹名保持一致",
"id": "solar_system_3d",
"name_comment": "首页卡片显示名称",
"name": "3D 太阳系",
"icon_comment": "首页卡片图标(emoji)",
"icon": "🪐",
"desc_comment": "一句话简介,作为卡片 tooltip 展示",
"desc": "JPL J2000 真实轨道要素 · 开普勒方程实时演算",
"sort_order_comment": "卡片排序,数值越小越靠前",
"sort_order": 30,
"enabled_comment": "是否启用(false 时列表接口不返回该 lab)",
"enabled": true
}
@@ -0,0 +1,15 @@
{
"_comment": "思想实验室 · 日地轨道:太阳-地球运行轨迹 2D 动画,以夸张化偏心率直观演示开普勒第二定律(近日点快、远日点慢),并按同一真近点角用真实偏心率 0.0167 同步换算真实日地距离。本 lab 完全独立;未来若需要数据库、OSS 等后台逻辑,统一放在本文件夹内。",
"id_comment": "lab 唯一标识(snake_case),与文件夹名保持一致",
"id": "sun_earth_orbit",
"name_comment": "首页卡片显示名称",
"name": "日地轨道",
"icon_comment": "首页卡片图标(emoji)",
"icon": "☀️",
"desc_comment": "一句话简介,作为卡片 tooltip 展示",
"desc": "开普勒第二定律动画 · 近日点快 / 远日点慢",
"sort_order_comment": "卡片排序,数值越小越靠前",
"sort_order": 40,
"enabled_comment": "是否启用(false 时列表接口不返回该 lab)",
"enabled": true
}