4492b4106a
1. 新增怀旧弹珠台、人性与投资、穿越三体、四维空间四个思想实验室项目 2. 为私有剪贴板添加业务层授权校验,优化上传进度显示与鉴权失败处理 3. 新增多个调试与单元测试脚本,覆盖三体积分、弹珠台物理、集成测试等场景 4. 补全穿越三体项目的完整HTML页面资源
540 lines
24 KiB
HTML
540 lines
24 KiB
HTML
<!-- ============================================================
|
||
思想实验室 · 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>0,冷色 = w<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>
|