Files
yangxiangyuan 4492b4106a feat: 新增3个思想实验室项目,完善私有剪贴板授权与上传功能,添加配套测试脚本
1. 新增怀旧弹珠台、人性与投资、穿越三体、四维空间四个思想实验室项目
2. 为私有剪贴板添加业务层授权校验,优化上传进度显示与鉴权失败处理
3. 新增多个调试与单元测试脚本,覆盖三体积分、弹珠台物理、集成测试等场景
4. 补全穿越三体项目的完整HTML页面资源
2026-08-07 17:49:24 +08:00

540 lines
24 KiB
HTML
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<!-- ============================================================
思想实验室 · labs/four_dim_space · 四维空间实验室
第四空间维的数学可视化(科学教研用),单文件自包含(Canvas 2D,零依赖)。
内容:正多胞体严格坐标 + 六旋转平面 + 等倾双旋转的 4D→3D→2D 透视投影;
四维超球被三维空间扫过的切片(r=√(R²−w²));维度类比表。
科学边界:观测空间为 3 维;额外空间维仅见于弦论/卡鲁扎-克莱因且
紧致于普朗克尺度(~1.6e-35 m),无直接观测证据。本 lab 是数学可视化工具。
服务端配置:src/server/thought_lab/labs/four_dim_space/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; box-sizing: border-box; }
body {
background: #0b0e1a; color: #c9d0e8;
font-family: "Segoe UI", "Microsoft YaHei", sans-serif;
display: flex; flex-direction: column; height: 100vh; overflow: hidden;
}
header {
display: flex; align-items: center; gap: 12px; padding: 10px 18px;
background: #0d1226; border-bottom: 1px solid #232c4e;
}
header h1 { font-size: 16px; color: #e8f0ff; font-weight: 600; }
header .sub { font-size: 12px; color: #66779f; }
header button {
margin-left: auto; background: #1a2140; color: #9db1ea; border: 1px solid #2c3763;
border-radius: 6px; padding: 4px 12px; font-size: 12px; cursor: pointer;
}
header button:hover { background: #232c52; }
#tab-bar { display: flex; background: #0d1226; border-bottom: 2px solid #232c4e; padding: 0 16px; }
.tab-btn {
background: none; color: #66779f; border: none; border-bottom: 2px solid transparent;
padding: 8px 18px; font-size: 13px; cursor: pointer; margin-bottom: -2px;
}
.tab-btn:hover { color: #9db1ea; }
.tab-btn.active { color: #e8f0ff; border-bottom-color: #6d84e8; }
.tab-content { flex: 1; display: flex; min-height: 0; }
.tab-content[hidden] { display: none; }
.stage { flex: 1; position: relative; min-width: 0; }
.stage canvas { position: absolute; inset: 0; display: block; }
aside {
width: 260px; background: #0d1226; border-left: 1px solid #232c4e;
padding: 12px; overflow-y: auto; font-size: 12px;
}
aside h2 { font-size: 13px; color: #9db1ea; margin: 12px 0 6px; }
aside h2:first-child { margin-top: 0; }
.row { display: flex; align-items: center; gap: 6px; margin: 6px 0; }
.row label { width: 76px; color: #7f92c9; flex: none; }
.row input[type=range] { flex: 1; accent-color: #6d84e8; }
.row .val { width: 44px; text-align: right; color: #b9c7f2; font-family: Consolas, monospace; font-size: 11px; }
.row select, .row button {
background: #1a2140; color: #c9d0e8; border: 1px solid #2c3763; border-radius: 6px;
padding: 4px 8px; font-size: 12px; cursor: pointer;
}
.row input[type=checkbox] { accent-color: #6d84e8; }
#facts {
background: #101736; border: 1px solid #232c4e; border-radius: 8px;
padding: 8px 10px; line-height: 1.7; color: #b9c7f2; font-family: Consolas, monospace; font-size: 11px;
}
.group-note { color: #55628a; font-size: 11px; margin: 2px 0 4px; }
/* 类比页签 */
#tab-analogy { overflow-y: auto; padding: 20px 28px; display: block; }
#tab-analogy table { border-collapse: collapse; margin: 12px 0; font-size: 13px; }
#tab-analogy th, #tab-analogy td { border: 1px solid #2c3763; padding: 6px 14px; text-align: center; }
#tab-analogy th { background: #101736; color: #9db1ea; }
#tab-analogy td.f { color: #55628a; font-family: Consolas, monospace; font-size: 12px; }
#tab-analogy p { margin: 10px 0; line-height: 1.8; max-width: 860px; }
#tab-analogy .hl { color: #ffd479; }
/* 信息抽屉 */
#drawer {
position: fixed; top: 0; right: -420px; width: 400px; height: 100vh;
background: #0d1226; border-left: 1px solid #2c3763; padding: 18px;
overflow-y: auto; transition: right .25s; z-index: 50; font-size: 13px; line-height: 1.8;
}
#drawer.open { right: 0; }
#drawer h3 { color: #9db1ea; margin: 14px 0 6px; font-size: 14px; }
#drawer h3:first-child { margin-top: 0; }
#drawer .warn { color: #ffb0b0; }
#drawer-close { float: right; background: none; border: none; color: #66779f; font-size: 16px; cursor: pointer; }
</style>
</head>
<body>
<header>
<h1>💠 四维空间实验室</h1>
<span class="sub">第四空间维的数学可视化 · 科学教研</span>
<button id="btn-info" type="button">科学说明</button>
</header>
<div id="tab-bar">
<button class="tab-btn active" data-tab="poly" type="button">正多胞体投影</button>
<button class="tab-btn" data-tab="slice" type="button">超球切片</button>
<button class="tab-btn" data-tab="analogy" type="button">维度类比</button>
</div>
<div id="tab-poly" class="tab-content">
<div class="stage"><canvas id="cv"></canvas></div>
<aside>
<h2>对象</h2>
<div class="row"><label>正多胞体</label>
<select id="sel-poly">
<option value="p8" selected>8-胞体(超立方体)</option>
<option value="p5">5-胞体(四维单形)</option>
<option value="p16">16-胞体(正八胞体)</option>
<option value="p24">24-胞体</option>
<option value="p600">600-胞体</option>
</select>
</div>
<div id="facts"></div>
<h2>旋转平面</h2>
<div class="group-note">3D 里就有的 3 个平面:</div>
<div class="row"><label>XY 平面</label><input id="r-xy" type="range" min="-180" max="180" step="1" value="0"><span class="val" id="v-xy">0°</span></div>
<div class="row"><label>XZ 平面</label><input id="r-xz" type="range" min="-180" max="180" step="1" value="0"><span class="val" id="v-xz">0°</span></div>
<div class="row"><label>YZ 平面</label><input id="r-yz" type="range" min="-180" max="180" step="1" value="0"><span class="val" id="v-yz">0°</span></div>
<div class="group-note">涉及第四维 W 的 3 个新平面(4D 特有):</div>
<div class="row"><label>XW 平面</label><input id="r-xw" type="range" min="-180" max="180" step="1" value="0"><span class="val" id="v-xw">0°</span></div>
<div class="row"><label>YW 平面</label><input id="r-yw" type="range" min="-180" max="180" step="1" value="0"><span class="val" id="v-yw">0°</span></div>
<div class="row"><label>ZW 平面</label><input id="r-zw" type="range" min="-180" max="180" step="1" value="0"><span class="val" id="v-zw">0°</span></div>
<h2>运动</h2>
<div class="row"><label>自动旋转</label><input id="c-auto" type="checkbox" checked><span class="val" style="width:auto;color:#55628a">XW 慢速</span></div>
<div class="row"><label>等倾旋转</label><input id="c-iso" type="checkbox"><span class="val" style="width:auto;color:#55628a">XY+ZW 同步(4D 特有)</span></div>
<h2>投影</h2>
<div class="row"><label>透视强度</label><input id="r-d4" type="range" min="1.6" max="8" step="0.1" value="3"><span class="val" id="v-d4">3.0</span></div>
<div class="row"><button id="btn-reset" type="button">重置视角</button></div>
</aside>
</div>
<div id="tab-slice" class="tab-content" hidden>
<div class="stage"><canvas id="cv-slice"></canvas></div>
<aside>
<h2>四维超球切片</h2>
<div class="row"><label>球心位置 w</label><input id="r-w" type="range" min="-1" max="1" step="0.01" value="0"><span class="val" id="v-w">0.00</span></div>
<div id="slice-facts" style="background:#101736;border:1px solid #232c4e;border-radius:8px;padding:8px 10px;line-height:1.7;color:#b9c7f2;font-family:Consolas,monospace;font-size:11px"></div>
<h2>说明</h2>
<p style="color:#7f92c9;line-height:1.7">
四维球(半径 R=1)被我们的三维空间「扫过」:当球心在第四维坐标 w 处,
截面是一个三维球,半径 r = √(R²−w²)(勾股定理)。<br><br>
左图是降一维的类比:三维球扫过二维平面,截面是圆;
右图是我们的处境:四维球扫过三维空间,截面是球。
</p>
</aside>
</div>
<div id="tab-analogy" class="tab-content" hidden>
<p>从 0 维到 4 维,「超立方体家族」的计数有严格公式:顶点 = 2ⁿ,边 = n·2ⁿ⁻¹,正方形面 = C(n,2)·2ⁿ⁻²,立方体胞 = C(n,3)·2ⁿ⁻³。下表数值全部由公式算出:</p>
<table>
<tr><th>维度</th><th>名称</th><th>顶点</th><th>边</th><th>正方形面</th><th>立方体胞</th></tr>
<tr><td>0</td><td>点</td><td>1</td><td>—</td><td>—</td><td>—</td></tr>
<tr><td>1</td><td>线段</td><td>2</td><td>1</td><td>—</td><td>—</td></tr>
<tr><td>2</td><td>正方形</td><td>4</td><td>4</td><td>1</td><td>—</td></tr>
<tr><td>3</td><td>立方体</td><td>8</td><td>12</td><td>6</td><td>1</td></tr>
<tr><td>4</td><td class="hl">超立方体</td><td>16</td><td>32</td><td>24</td><td>8</td></tr>
</table>
<p><span class="hl">为什么 4D 特别?</span>凸正多胞体(各维的「正多面体」)的个数:2D 有无穷多种(正 n 边形),3D 有 5 种(柏拉图立体),<b>4D 有 6 种</b>(本 lab 渲染其中 5 种),而 5 维及以上只剩 3 种。第四维是正多胞体最「丰富」的维度。</p>
<p><span class="hl">两种认知手段</span>(Flatland 类比):一个生活在二维平面里的正方形,无法直接看到三维立方体,但它可以 (1) 看立方体在平面上的<b>投影</b>(影子),(2) 观察立方体穿过平面时的<b>切片</b>(一段段变化的线段)。我们对第四维做同样的事:投影页签 = 手段 1,切片页签 = 手段 2。</p>
<p><span class="hl">旋转平面数</span>:n 维空间的独立旋转平面数 = n(n−1)/2。3D 有 3 个(XY/XZ/YZ),4D 有 <b>6 个</b>——多出的 XW/YW/ZW 三个平面正是投影页签里「4D 特有」的那组滑块。</p>
</div>
<div id="drawer">
<button id="drawer-close" type="button">✕</button>
<h3>这个 lab 在做什么</h3>
<p>可视化<b>第四个空间维</b>——一个与 x/y/z 都垂直的纯数学方向。所有几何对象(正多胞体坐标、超球切片半径)都是严格数学,顶点数/边数由代码实算并经单元测试校验,不是示意性的瞎画。</p>
<h3>科学边界(重要)</h3>
<p class="warn">迄今所有观测都表明宏观空间是 3 维的。额外空间维只出现在卡鲁扎-克莱因理论与弦论中,且被紧致化在普朗克尺度(约 1.6×10⁻³⁵ m),目前没有任何直接观测证据。本 lab 是<b>数学可视化工具</b>,用于理解「如果存在第四空间维,几何会怎样」,不宣称宇宙真的如此。</p>
<h3>与时空 4D 的区别</h3>
<p>相对论的「四维」是 3 维空间 + 1 维时间(闵可夫斯基时空,时间维带负号度规),见本实验室的「闵可夫斯基画板」。本 lab 的第四维是<b>空间维</b>(正定度规),两者数学结构不同,不要混淆。</p>
<h3>投影方法</h3>
<p>两级透视投影:4D→3D(相机在 w 轴距离 d 处,s = d/(d−w)),再 3D→2D。w 越接近相机,投影越大——正如超立方体投影里「外面的大立方体」其实离我们在第四维上更近。颜色编码 w 坐标:暖色 = w&gt;0,冷色 = w&lt;0。</p>
<h3>等倾旋转</h3>
<p>4D 特有现象:两个完全正交的平面(如 XY 与 ZW)可以同时以相同角速度旋转、互不干扰,称为等倾(双)旋转。3D 中任何旋转都只有一个旋转平面,做不到这一点。</p>
</div>
<script>
'use strict';
// ==== POLYTOPE_MATH_BEGIN ====
// 纯数学块:本块被 dev_test_scripts/unit/test_four_dim_polytopes.js 抽取校验
const PHI = (1 + Math.sqrt(5)) / 2
const permParity = (p) => {
let inv = 0
for (let i = 0; i < p.length; i++)
for (let j = i + 1; j < p.length; j++)
if (p[i] > p[j]) inv++
return inv % 2 === 0 ? 1 : -1
}
const perms4 = (() => {
const all = []
const rec = (used, cur) => {
if (cur.length === 4) { all.push(cur.slice()); return }
for (let i = 0; i < 4; i++) {
if (used[i]) continue
used[i] = true; cur.push(i); rec(used, cur); cur.pop(); used[i] = false
}
}
rec([false, false, false, false], [])
return all
})()
const evenPerms4 = perms4.filter(p => permParity(p) === 1)
// 5-胞体(四维单形):5 顶点,边长 2√2,外接半径 4/√5
const verts5Cell = () => {
const a = 1 / Math.sqrt(5)
return [
[1, 1, 1, -a], [1, -1, -1, -a], [-1, 1, -1, -a], [-1, -1, 1, -a], [0, 0, 0, 4 * a]
]
}
// 8-胞体(超立方体):(±1)⁴
const vertsTesseract = () => {
const out = []
for (let m = 0; m < 16; m++) out.push([m & 1 ? 1 : -1, m & 2 ? 1 : -1, m & 4 ? 1 : -1, m & 8 ? 1 : -1])
return out
}
// 16-胞体(正八胞体):6 个轴向 ±1
const verts16Cell = () => {
const out = []
for (let i = 0; i < 4; i++) for (const s of [1, -1]) { const v = [0, 0, 0, 0]; v[i] = s; out.push(v) }
return out
}
// 24-胞体:所有 (±1,±1,0,0) 型排列
const verts24Cell = () => {
const out = []
for (let i = 0; i < 4; i++) for (let j = i + 1; j < 4; j++)
for (const si of [1, -1]) for (const sj of [1, -1]) {
const v = [0, 0, 0, 0]; v[i] = si; v[j] = sj; out.push(v)
}
return out
}
// 600-胞体 = 二进二十面体群(120 个单位四元数),外接半径 1:
// 8 轴向 + 16 半立方 + 96 偶排列(±φ/2, ±1/(2φ), ±1/2, 0)
const verts600Cell = () => {
const out = []
for (let i = 0; i < 4; i++) for (const s of [1, -1]) { const v = [0, 0, 0, 0]; v[i] = s; out.push(v) }
for (let m = 0; m < 16; m++) out.push([m & 1 ? 0.5 : -0.5, m & 2 ? 0.5 : -0.5, m & 4 ? 0.5 : -0.5, m & 8 ? 0.5 : -0.5])
const base = [PHI / 2, 1 / (2 * PHI), 0.5, 0]
for (const p of evenPerms4) for (let m = 0; m < 8; m++) {
const signed = [
(m & 1 ? 1 : -1) * base[0],
(m & 2 ? 1 : -1) * base[1],
(m & 4 ? 1 : -1) * base[2],
0
]
out.push([signed[p[0]], signed[p[1]], signed[p[2]], signed[p[3]]])
}
const seen = new Set(); const uniq = []
for (const v of out) {
const k = v.map(x => x.toFixed(9)).join(',')
if (!seen.has(k)) { seen.add(k); uniq.push(v) }
}
return uniq
}
// 凸正多胞体的边 = 最小距离顶点对(对全部 6 个凸正多胞体成立)
const edgesByMinDist = (verts, relTol = 1e-5) => {
const d2 = (a, b) => { let s = 0; for (let i = 0; i < 4; i++) { const d = a[i] - b[i]; s += d * d } return s }
let min = Infinity
for (let i = 0; i < verts.length; i++)
for (let j = i + 1; j < verts.length; j++)
min = Math.min(min, d2(verts[i], verts[j]))
const edges = []
const tol = min * relTol
for (let i = 0; i < verts.length; i++)
for (let j = i + 1; j < verts.length; j++)
if (d2(verts[i], verts[j]) - min < tol) edges.push([i, j])
return edges
}
// 在 (i,j) 平面旋转 ang 弧度
const rotate4 = (v, i, j, ang) => {
const c = Math.cos(ang), s = Math.sin(ang)
const out = v.slice()
out[i] = v[i] * c - v[j] * s
out[j] = v[i] * s + v[j] * c
return out
}
// 4D→3D 透视投影(相机在 w 轴 d 处)
const project4to3 = (v, d) => {
const s = d / (d - v[3])
return [v[0] * s, v[1] * s, v[2] * s, v[3]]
}
// 3D→2D 透视投影(相机在 z 轴 d 处)
const project3to2 = (v, d) => {
const s = d / (d - v[2])
return [v[0] * s, v[1] * s]
}
const POLYTOPES = {
p5: { name: '5-胞体(四维单形)', verts: verts5Cell, expect: { v: 5, e: 10 } },
p8: { name: '8-胞体(超立方体)', verts: vertsTesseract, expect: { v: 16, e: 32 } },
p16: { name: '16-胞体(正八胞体)', verts: verts16Cell, expect: { v: 8, e: 24 } },
p24: { name: '24-胞体', verts: verts24Cell, expect: { v: 24, e: 96 } },
p600: { name: '600-胞体', verts: verts600Cell, expect: { v: 120, e: 720 } }
}
// ==== POLYTOPE_MATH_END ====
// ============================================================
// 渲染与交互
// ============================================================
const $ = id => document.getElementById(id)
const cv = $('cv'), ctx = cv.getContext('2d')
const cvS = $('cv-slice'), ctxS = cvS.getContext('2d')
const DPR = Math.min(window.devicePixelRatio || 1, 2)
const state = {
tab: 'poly',
polyKey: 'p8',
angles: { xy: 0, xz: 0, yz: 0, xw: 0, yw: 0, zw: 0 },
auto: true,
iso: false,
isoT: 0,
d4: 3,
w: 0
}
// 多胞体缓存(坐标/边只算一次)
const cache = {}
const getPoly = (key) => {
if (!cache[key]) {
const verts = POLYTOPES[key].verts()
cache[key] = { verts, edges: edgesByMinDist(verts) }
}
return cache[key]
}
const resize = () => {
for (const [c, el] of [[cv, cv.parentElement], [cvS, cvS.parentElement]]) {
const r = el.getBoundingClientRect()
c.width = Math.max(1, Math.round(r.width * DPR))
c.height = Math.max(1, Math.round(r.height * DPR))
}
}
window.addEventListener('resize', resize)
// w 坐标 → 颜色(暖 = w>0,冷 = w<0)
const wColor = (w, alpha) => {
const t = Math.max(-1, Math.min(1, w / 1.4))
const r = t > 0 ? 255 : Math.round(110 + 145 * (1 + t))
const g = Math.round(150 + 60 * (1 - Math.abs(t)))
const b = t < 0 ? 255 : Math.round(110 + 145 * (1 - t))
return `rgba(${r},${g},${b},${alpha})`
}
const drawPoly = () => {
const W = cv.width, H = cv.height
ctx.setTransform(1, 0, 0, 1, 0, 0)
ctx.fillStyle = '#0b0e1a'
ctx.fillRect(0, 0, W, H)
const P = getPoly(state.polyKey)
const a = state.angles
const isoXY = state.iso ? state.isoT : 0
const isoZW = state.iso ? state.isoT : 0
const pts2d = []
const ws = []
for (const v of P.verts) {
let q = v
q = rotate4(q, 0, 1, a.xy + isoXY)
q = rotate4(q, 0, 2, a.xz)
q = rotate4(q, 1, 2, a.yz)
q = rotate4(q, 0, 3, a.xw)
q = rotate4(q, 1, 3, a.yw)
q = rotate4(q, 2, 3, a.zw + isoZW)
const p3 = project4to3(q, state.d4)
const p2 = project3to2(p3, 6)
pts2d.push(p2)
ws.push(q[3])
}
const R = Math.min(W, H) * 0.30
const cx = W / 2, cy = H / 2
// 边
for (const [i, j] of P.edges) {
const wm = (ws[i] + ws[j]) / 2
const alpha = 0.25 + 0.45 * ((wm + 1.4) / 2.8)
ctx.strokeStyle = wColor(wm, alpha)
ctx.lineWidth = Math.max(1, DPR * (0.6 + 0.8 * ((wm + 1.4) / 2.8)))
ctx.beginPath()
ctx.moveTo(cx + pts2d[i][0] * R, cy - pts2d[i][1] * R)
ctx.lineTo(cx + pts2d[j][0] * R, cy - pts2d[j][1] * R)
ctx.stroke()
}
// 顶点
for (let i = 0; i < pts2d.length; i++) {
const t = (ws[i] + 1.4) / 2.8
ctx.fillStyle = wColor(ws[i], 0.5 + 0.5 * t)
ctx.beginPath()
ctx.arc(cx + pts2d[i][0] * R, cy - pts2d[i][1] * R, Math.max(1.5, DPR * (1 + 2 * t)), 0, Math.PI * 2)
ctx.fill()
}
}
const drawSlice = () => {
const W = cvS.width, H = cvS.height
ctxS.setTransform(1, 0, 0, 1, 0, 0)
ctxS.fillStyle = '#0b0e1a'
ctxS.fillRect(0, 0, W, H)
const w = state.w
const R = 1
const r = Math.sqrt(Math.max(0, R * R - w * w))
const cxL = W * 0.28, cxR = W * 0.72, cy = H * 0.42
const S = Math.min(W, H) * 0.26
// ---- 左:2D 类比(3D 球扫过 2D 平面) ----
ctxS.strokeStyle = '#3a4670'; ctxS.lineWidth = DPR
ctxS.beginPath(); ctxS.moveTo(cxL - S * 1.6, cy); ctxS.lineTo(cxL + S * 1.6, cy); ctxS.stroke()
// 3D 球轮廓(侧视)
ctxS.strokeStyle = 'rgba(120,140,220,0.5)'
ctxS.beginPath(); ctxS.arc(cxL, cy - 0, S, 0, Math.PI * 2); ctxS.stroke()
// w 高度线(球心在平面上方 w 处 → 这里画球心抬升 w*S,截面在平面线上)
const chord = r * S
ctxS.strokeStyle = '#ffd479'; ctxS.lineWidth = 2 * DPR
ctxS.beginPath(); ctxS.moveTo(cxL - chord, cy); ctxS.lineTo(cxL + chord, cy); ctxS.stroke()
ctxS.fillStyle = '#7f92c9'; ctxS.font = `${12 * DPR}px "Microsoft YaHei"`
ctxS.textAlign = 'center'
ctxS.fillText('类比:3D 球扫过 2D 平面 → 截面是圆(平面里看到线段)', cxL, cy + S * 1.45)
// ---- 右:我们的处境(4D 球扫过 3D 空间) ----
if (r > 0.001) {
const g = ctxS.createRadialGradient(cxR - r * S * 0.3, cy - r * S * 0.3, r * S * 0.1, cxR, cy, r * S)
g.addColorStop(0, 'rgba(160,190,255,0.95)')
g.addColorStop(1, 'rgba(50,70,160,0.85)')
ctxS.fillStyle = g
ctxS.beginPath(); ctxS.arc(cxR, cy, r * S, 0, Math.PI * 2); ctxS.fill()
ctxS.strokeStyle = '#8fb0ff'; ctxS.lineWidth = DPR
ctxS.beginPath(); ctxS.arc(cxR, cy, r * S, 0, Math.PI * 2); ctxS.stroke()
} else {
ctxS.fillStyle = '#8fb0ff'
ctxS.beginPath(); ctxS.arc(cxR, cy, 2 * DPR, 0, Math.PI * 2); ctxS.fill()
}
ctxS.fillStyle = '#7f92c9'
ctxS.fillText('我们:4D 球扫过 3D 空间 → 截面是 3D 球(轮廓是圆)', cxR, cy + S * 1.45)
// ---- 底部:r(w) 曲线 ----
const gx = W * 0.5, gy = H * 0.88, gw = W * 0.3, gh = H * 0.09
ctxS.strokeStyle = '#3a4670'; ctxS.lineWidth = DPR
ctxS.beginPath(); ctxS.moveTo(gx - gw / 2, gy); ctxS.lineTo(gx + gw / 2, gy); ctxS.stroke()
ctxS.strokeStyle = '#6d84e8'
ctxS.beginPath()
for (let i = 0; i <= 100; i++) {
const ww = -1 + 2 * i / 100
const rr = Math.sqrt(Math.max(0, 1 - ww * ww))
const px = gx + (ww / 1) * gw / 2
const py = gy - rr * gh
i === 0 ? ctxS.moveTo(px, py) : ctxS.lineTo(px, py)
}
ctxS.stroke()
ctxS.fillStyle = '#ffd479'
ctxS.beginPath(); ctxS.arc(gx + w * gw / 2, gy - r * gh, 3 * DPR, 0, Math.PI * 2); ctxS.fill()
ctxS.fillStyle = '#55628a'
ctxS.fillText('r(w) = √(R² − w²)', gx, gy + 14 * DPR)
$('slice-facts').innerHTML =
`w = ${w.toFixed(2)}<br>r = √(1 − w²) = ${r.toFixed(3)}<br>` +
(r < 0.001 ? '状态:相切(一个点)' : (Math.abs(w) < 0.001 ? '状态:球心穿过,截面最大' : '状态:截面球存在'))
}
// ---- 面板绑定 ----
const PLANE_IDS = ['xy', 'xz', 'yz', 'xw', 'yw', 'zw']
PLANE_IDS.forEach(k => {
$('r-' + k).addEventListener('input', function () {
state.angles[k] = parseFloat(this.value) * Math.PI / 180
$('v-' + k).textContent = this.value + '°'
})
})
$('sel-poly').addEventListener('change', function () {
state.polyKey = this.value
updateFacts()
})
$('c-auto').addEventListener('change', function () { state.auto = this.checked })
$('c-iso').addEventListener('change', function () { state.iso = this.checked; state.isoT = 0 })
$('r-d4').addEventListener('input', function () {
state.d4 = parseFloat(this.value)
$('v-d4').textContent = state.d4.toFixed(1)
})
$('r-w').addEventListener('input', function () {
state.w = parseFloat(this.value)
$('v-w').textContent = state.w.toFixed(2)
})
$('btn-reset').addEventListener('click', () => {
state.angles = { xy: 0, xz: 0, yz: 0, xw: 0, yw: 0, zw: 0 }
state.isoT = 0
PLANE_IDS.forEach(k => { $('r-' + k).value = '0'; $('v-' + k).textContent = '0°' })
})
const updateFacts = () => {
const P = getPoly(state.polyKey)
const norms = P.verts.map(v => Math.sqrt(v.reduce((s, x) => s + x * x, 0)))
const rMin = Math.min(...norms).toFixed(4), rMax = Math.max(...norms).toFixed(4)
$('facts').innerHTML =
`${POLYTOPES[state.polyKey].name}<br>` +
`顶点 ${P.verts.length} · 边 ${P.edges.length}<br>` +
`外接半径 ${rMin}(全部相等:${rMin === rMax ? '是' : '否'})`
}
// ---- 页签 ----
document.querySelectorAll('#tab-bar .tab-btn').forEach(btn => {
btn.addEventListener('click', () => {
state.tab = btn.dataset.tab
document.querySelectorAll('#tab-bar .tab-btn').forEach(b => b.classList.toggle('active', b === btn))
$('tab-poly').hidden = state.tab !== 'poly'
$('tab-slice').hidden = state.tab !== 'slice'
$('tab-analogy').hidden = state.tab !== 'analogy'
resize()
})
})
// ---- 信息抽屉 ----
$('btn-info').addEventListener('click', () => $('drawer').classList.add('open'))
$('drawer-close').addEventListener('click', () => $('drawer').classList.remove('open'))
// ---- 主循环 ----
let lastT = performance.now()
const loop = (now) => {
const dt = Math.min(0.05, (now - lastT) / 1000)
lastT = now
if (state.auto) {
state.angles.xw += dt * 0.35
const deg = Math.round(((state.angles.xw * 180 / Math.PI + 180) % 360 + 360) % 360 - 180)
$('r-xw').value = String(deg); $('v-xw').textContent = deg + '°'
}
if (state.iso) state.isoT += dt * 0.5
if (state.tab === 'poly') drawPoly()
else if (state.tab === 'slice') drawSlice()
requestAnimationFrame(loop)
}
resize()
updateFacts()
requestAnimationFrame(loop)
</script>
</body>
</html>