Files
Toolbox/public/tools/thought_lab/labs/minkowski_inkscape/index.html
T
yangxiangyuan 876a3b8768 feat(thought-lab): 新增万花筒与闵可夫斯基画板两个思想实验室
新增两个独立思想实验室:
1. 万花筒模拟器:支持可调参数物理万花筒效果,配置方案SQLite持久化
2. 闵可夫斯基画板:支持世界线绘制、洛伦兹变换、光锥因果判定
同时添加自动绑定实验室后端的逻辑,以及配套的单元测试与前端页面
2026-08-06 17:41:43 +08:00

624 lines
25 KiB
HTML
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<!-- ============================================================
思想实验室 · labs/minkowski_inkscape · 闵可夫斯基画板
「闵可夫斯基空间里的 Inkscape」:
- 钢笔/节点工具画世界线,线段按类时/类光/类空实时着色
- 快度 φ 滑块做洛伦兹 boost(主动/被动双视图 + 扫掠动画)
- 点击事件展开光锥,选中两事件做因果判定
独立 lab:单文件自包含(Canvas 2D,零依赖),不引用其他 lab 的任何资源。
服务端配置:src/server/thought_lab/labs/minkowski_inkscape/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>闵可夫斯基画板 · 世界线 / 洛伦兹 boost / 光锥</title>
<style>
* { margin: 0; padding: 0; box-sizing: border-box; }
html, body { height: 100%; }
body {
display: flex; flex-direction: column;
background: #0b1020; color: #dfe7ff;
font-family: "Segoe UI", "Microsoft YaHei", sans-serif;
overflow: hidden;
}
header {
padding: 8px 16px; display: flex; align-items: baseline; gap: 12px;
background: #101733; border-bottom: 1px solid #26305c;
}
header h1 { font-size: 16px; font-weight: 600; }
header .sub { font-size: 12px; color: #8fa1d8; }
#toolbar {
display: flex; flex-wrap: wrap; gap: 14px; align-items: center;
padding: 8px 16px; background: #0e142c; border-bottom: 1px solid #26305c;
font-size: 12px;
}
.tgroup { display: flex; align-items: center; gap: 6px; }
.tgroup .glabel { color: #7f92c9; margin-right: 2px; }
button {
background: #1b2547; color: #dfe7ff; border: 1px solid #33407a;
border-radius: 6px; padding: 4px 10px; font-size: 12px; cursor: pointer;
}
button:hover { background: #24315e; }
button.active { background: #3b57c4; border-color: #6d84e8; }
button.danger { border-color: #7a3340; }
button.danger:hover { background: #5a2531; }
input[type=range] { width: 160px; accent-color: #6d84e8; }
.phi-read { min-width: 118px; color: #ffd54a; font-family: Consolas, monospace; }
#main { flex: 1; display: flex; min-height: 0; }
#canvas-wrap { flex: 1; position: relative; min-width: 0; }
#cv { position: absolute; inset: 0; display: block; }
#inspector {
width: 300px; background: #0e142c; border-left: 1px solid #26305c;
padding: 12px; overflow-y: auto; font-size: 12px; line-height: 1.7;
}
#inspector h2 { font-size: 13px; margin: 10px 0 4px; color: #9db1ea; }
#inspector h2:first-child { margin-top: 0; }
#inspector .card {
background: #131b3a; border: 1px solid #26305c; border-radius: 8px;
padding: 8px 10px; margin-bottom: 8px; font-family: Consolas, monospace;
white-space: pre-wrap; word-break: break-all;
}
#inspector .empty { color: #66779f; font-family: "Segoe UI", "Microsoft YaHei", sans-serif; }
.c-time { color: #4da3ff; } .c-light { color: #ffd54a; } .c-space { color: #ff5d5d; }
#help { color: #8fa1d8; }
#help li { margin-left: 16px; }
/* 危险操作确认弹窗(项目规范:禁用原生 confirm) */
#confirm-mask {
position: fixed; inset: 0; background: rgba(0,0,0,.55);
display: flex; align-items: center; justify-content: center; z-index: 99;
}
#confirm-mask[hidden] { display: none; }
#confirm-box {
background: #131b3a; border: 1px solid #7a3340; border-radius: 10px;
padding: 18px 22px; max-width: 340px; text-align: center;
}
#confirm-box p { margin-bottom: 14px; font-size: 13px; }
#confirm-box .row { display: flex; gap: 10px; justify-content: center; }
</style>
</head>
<body>
<header>
<h1>📐 闵可夫斯基画板</h1>
<span class="sub">闵可夫斯基空间里的 Inkscape —— 画世界线 · 做洛伦兹变换 · 看光锥(c = 1)</span>
</header>
<div id="toolbar">
<div class="tgroup">
<span class="glabel">工具</span>
<button id="tool-pen" class="active" type="button">钢笔</button>
<button id="tool-select" type="button">选择/节点</button>
<button id="tool-cone" type="button">光锥</button>
</div>
<div class="tgroup">
<span class="glabel">世界线</span>
<button id="btn-newline" type="button">新建世界线</button>
<button id="btn-delnode" type="button">删除选中节点</button>
<button id="btn-clear" class="danger" type="button">清空画布</button>
</div>
<div class="tgroup">
<span class="glabel">视图</span>
<button id="view-active" class="active" type="button">主动</button>
<button id="view-passive" type="button">被动</button>
</div>
<div class="tgroup">
<span class="glabel">boost φ</span>
<input id="phi" type="range" min="-2" max="2" step="0.01" value="0">
<span class="phi-read" id="phi-read">φ=0.00 v=0.00c</span>
<button id="btn-sweep" type="button">扫掠动画</button>
</div>
<div class="tgroup">
<button id="btn-clearcone" type="button">清除光锥</button>
</div>
</div>
<div id="main">
<div id="canvas-wrap"><canvas id="cv"></canvas></div>
<aside id="inspector">
<h2>线段检查器</h2>
<div class="card" id="ins-seg"><span class="empty">选择模式下点击某条线段,读出 Δt / Δx / Δs² / v / τ。</span></div>
<h2>事件检查器</h2>
<div class="card" id="ins-node"><span class="empty">点击节点查看 S 系与 S′ 系坐标。</span></div>
<h2>因果判定</h2>
<div class="card" id="ins-pair"><span class="empty">选择模式下:点击节点选中,Shift+点击第二个节点,判定两事件因果关系。</span></div>
<h2>图例</h2>
<div class="card"><span class="c-time">■ 类时 |Δt|&gt;|Δx|(v&lt;1,可携带因果)</span>
<span class="c-light">■ 类光 |Δt|=|Δx|(光子世界线)</span>
<span class="c-space">■ 类空 |Δx|&gt;|Δt|(v&gt;1,无因果)</span>
<span style="color:#b48cff">■ 紫色斜轴 = S′ 系(被动视图)</span></div>
<h2>帮助</h2>
<div id="help"><ul>
<li>钢笔:点击画布放置事件节点,自动连成世界线。</li>
<li>选择/节点:拖动节点;点线段看读数;Shift+点两节点做因果判定;Delete 删除选中节点;Esc 取消选择。</li>
<li>光锥:点击任意位置(自动吸附附近节点)放置光锥事件,可拖动。</li>
<li>主动视图:轴不动,整幅画被 boost;被动视图:画不动,t′/x′ 轴与网格剪刀式倾斜。</li>
<li>间隔分类是洛伦兹不变量:boost 不改变线段颜色。</li>
</ul></div>
</aside>
</div>
<div id="confirm-mask" hidden>
<div id="confirm-box">
<p>确定清空画布上的所有世界线与光锥吗?此操作不可撤销。</p>
<div class="row">
<button id="confirm-yes" class="danger" type="button">清空</button>
<button id="confirm-no" type="button">取消</button>
</div>
</div>
</div>
<script>
'use strict';
// ------------------------------------------------------------
// 状态与常量
// ------------------------------------------------------------
const state = {
tool: 'pen', // pen | select | cone
view: 'active', // active | passive
phi: 0, // 快度(boost 参数),v = tanh(phi)
scale: 46, // 像素 / 单位
lines: [], // [{id, nodes:[{t,x}]}]
nextLineId: 1,
activeLineId: 1,
selectedNode: null, // {line, node}
pair: [], // 因果判定的两个节点对象
selectedSeg: null, // {line, i}
cone: null // {t,x}
};
const COLORS = { time: '#4da3ff', light: '#ffd54a', space: '#ff5d5d' };
const LIGHT_TOL = 0.01; // |Δt|-|Δx| 小于该值视为类光
// ------------------------------------------------------------
// 物理工具
// ------------------------------------------------------------
const boost = (t, x, phi) => ({
t: Math.cosh(phi) * t - Math.sinh(phi) * x,
x: -Math.sinh(phi) * t + Math.cosh(phi) * x
});
const classify = (a, b) => {
const dt = b.t - a.t, dx = b.x - a.x;
if (Math.abs(Math.abs(dt) - Math.abs(dx)) < LIGHT_TOL) return 'light';
return Math.abs(dt) > Math.abs(dx) ? 'time' : 'space';
};
const CLASS_NAME = { time: '类时', light: '类光', space: '类空' };
const fmt = (v, n) => (Number.isFinite(v) ? v.toFixed(n === undefined ? 2 : n) : '—');
// ------------------------------------------------------------
// 画布与坐标变换
// ------------------------------------------------------------
const wrap = document.getElementById('canvas-wrap');
const cv = document.getElementById('cv');
const ctx = cv.getContext('2d');
let W = 0, H = 0, DPR = 1;
const rest2disp = p => state.view === 'active' ? boost(p.t, p.x, state.phi) : { t: p.t, x: p.x };
const disp2rest = p => state.view === 'active' ? boost(p.t, p.x, -state.phi) : { t: p.t, x: p.x };
const d2s = p => ({ x: W / 2 + p.x * state.scale, y: H / 2 - p.t * state.scale });
const s2d = (px, py) => ({ t: (H / 2 - py) / state.scale, x: (px - W / 2) / state.scale });
const nodeScreen = n => d2s(rest2disp(n));
function resize() {
DPR = window.devicePixelRatio || 1;
const r = wrap.getBoundingClientRect();
W = Math.max(1, r.width); H = Math.max(1, r.height);
cv.width = Math.round(W * DPR); cv.height = Math.round(H * DPR);
draw();
}
window.addEventListener('resize', resize);
// ------------------------------------------------------------
// 绘制
// ------------------------------------------------------------
function drawSquareGrid() {
const xMax = (W / 2) / state.scale, tMax = (H / 2) / state.scale;
ctx.strokeStyle = 'rgba(255,255,255,0.07)'; ctx.lineWidth = 1;
ctx.beginPath();
for (let k = Math.ceil(-xMax); k <= xMax; k++) {
const p = d2s({ t: -tMax, x: k }), q = d2s({ t: tMax, x: k });
ctx.moveTo(p.x, p.y); ctx.lineTo(q.x, q.y);
}
for (let k = Math.ceil(-tMax); k <= tMax; k++) {
const p = d2s({ t: k, x: -xMax }), q = d2s({ t: k, x: xMax });
ctx.moveTo(p.x, p.y); ctx.lineTo(q.x, q.y);
}
ctx.stroke();
}
function drawAxes() {
const p0 = d2s({ t: 0, x: -(W / 2) / state.scale }), p1 = d2s({ t: 0, x: (W / 2) / state.scale });
const q0 = d2s({ t: -(H / 2) / state.scale, x: 0 }), q1 = d2s({ t: (H / 2) / state.scale, x: 0 });
ctx.strokeStyle = 'rgba(255,255,255,0.45)'; ctx.lineWidth = 1.2;
ctx.beginPath();
ctx.moveTo(p0.x, p0.y); ctx.lineTo(p1.x, p1.y);
ctx.moveTo(q0.x, q0.y); ctx.lineTo(q1.x, q1.y);
ctx.stroke();
ctx.fillStyle = 'rgba(255,255,255,0.6)'; ctx.font = '12px Consolas, monospace';
const lbl = state.view === 'active' ? ["x′", "t′"] : ['x', 't'];
ctx.fillText(lbl[0], p1.x - 14, p1.y - 6);
ctx.fillText(lbl[1], q1.x + 6, q1.y + 12);
}
function drawLightRef() {
// 过原点的 45° 光线参考(洛伦兹不变)
const L = Math.max(W, H);
const o = d2s({ t: 0, x: 0 });
ctx.strokeStyle = 'rgba(255,213,74,0.28)'; ctx.setLineDash([5, 6]); ctx.lineWidth = 1;
ctx.beginPath();
ctx.moveTo(o.x - L, o.y - L); ctx.lineTo(o.x + L, o.y + L);
ctx.moveTo(o.x - L, o.y + L); ctx.lineTo(o.x + L, o.y - L);
ctx.stroke();
ctx.setLineDash([]);
}
function drawTiltedGrid() {
// 主动视图:原 S 系整数网格经 boost 后的倾斜网格(展示"画被变换")
const R = Math.max(W, H) / state.scale;
ctx.strokeStyle = 'rgba(255,255,255,0.06)'; ctx.lineWidth = 1;
ctx.beginPath();
for (let k = Math.ceil(-R); k <= R; k++) {
let a = boost(-R, k, state.phi), b = boost(R, k, state.phi); // x=k 竖线
let p = d2s(a), q = d2s(b);
ctx.moveTo(p.x, p.y); ctx.lineTo(q.x, q.y);
a = boost(k, -R, state.phi); b = boost(k, R, state.phi); // t=k 横线
p = d2s(a); q = d2s(b);
ctx.moveTo(p.x, p.y); ctx.lineTo(q.x, q.y);
}
ctx.stroke();
}
function drawPrimedAxes() {
// 被动视图:S′ 系斜轴 + 斜网格
const R = Math.max(W, H) / state.scale;
const sh = Math.sinh(state.phi), ch = Math.cosh(state.phi);
ctx.strokeStyle = 'rgba(180,140,255,0.20)'; ctx.lineWidth = 1;
ctx.beginPath();
for (let k = Math.ceil(-R); k <= R; k++) {
// x′=k:x = (k + sh*t)/ch,t 从 -R..R
let a = { t: -R, x: (k + sh * -R) / ch }, b = { t: R, x: (k + sh * R) / ch };
let p = d2s(a), q = d2s(b);
ctx.moveTo(p.x, p.y); ctx.lineTo(q.x, q.y);
// t′=k:t = (k + sh*x)/ch,x 从 -R..R
a = { t: (k + sh * -R) / ch, x: -R }; b = { t: (k + sh * R) / ch, x: R };
p = d2s(a); q = d2s(b);
ctx.moveTo(p.x, p.y); ctx.lineTo(q.x, q.y);
}
ctx.stroke();
// 斜主轴
const tA = d2s({ t: -R, x: Math.tanh(state.phi) * -R }), tB = d2s({ t: R, x: Math.tanh(state.phi) * R });
const xA = d2s({ t: Math.tanh(state.phi) * -R, x: -R }), xB = d2s({ t: Math.tanh(state.phi) * R, x: R });
ctx.strokeStyle = 'rgba(180,140,255,0.75)'; ctx.lineWidth = 1.4;
ctx.beginPath();
ctx.moveTo(tA.x, tA.y); ctx.lineTo(tB.x, tB.y);
ctx.moveTo(xA.x, xA.y); ctx.lineTo(xB.x, xB.y);
ctx.stroke();
ctx.fillStyle = 'rgba(180,140,255,0.9)'; ctx.font = '12px Consolas, monospace';
ctx.fillText("t′", tB.x + 6, tB.y + 12);
ctx.fillText("x′", xB.x - 16, xB.y - 6);
}
function drawCone() {
if (!state.cone) return;
const p = d2s(rest2disp(state.cone));
const L = Math.max(W, H);
// 未来/过去楔形
ctx.fillStyle = 'rgba(255,213,74,0.10)';
ctx.beginPath(); ctx.moveTo(p.x, p.y); ctx.lineTo(p.x - L, p.y - L); ctx.lineTo(p.x + L, p.y - L); ctx.closePath(); ctx.fill();
ctx.beginPath(); ctx.moveTo(p.x, p.y); ctx.lineTo(p.x - L, p.y + L); ctx.lineTo(p.x + L, p.y + L); ctx.closePath(); ctx.fill();
// 锥面
ctx.strokeStyle = 'rgba(255,213,74,0.65)'; ctx.setLineDash([6, 5]); ctx.lineWidth = 1.2;
ctx.beginPath();
ctx.moveTo(p.x - L, p.y - L); ctx.lineTo(p.x + L, p.y + L);
ctx.moveTo(p.x - L, p.y + L); ctx.lineTo(p.x + L, p.y - L);
ctx.stroke();
ctx.setLineDash([]);
// 事件标记
ctx.fillStyle = '#ffd54a';
ctx.beginPath(); ctx.arc(p.x, p.y, 4, 0, Math.PI * 2); ctx.fill();
}
function drawLines() {
state.lines.forEach((line, li) => {
// 线段
for (let i = 0; i < line.nodes.length - 1; i++) {
const a = nodeScreen(line.nodes[i]), b = nodeScreen(line.nodes[i + 1]);
const cls = classify(line.nodes[i], line.nodes[i + 1]);
if (state.selectedSeg && state.selectedSeg.line === line && state.selectedSeg.i === i) {
ctx.strokeStyle = 'rgba(255,255,255,0.30)'; ctx.lineWidth = 7;
ctx.beginPath(); ctx.moveTo(a.x, a.y); ctx.lineTo(b.x, b.y); ctx.stroke();
}
ctx.strokeStyle = COLORS[cls]; ctx.lineWidth = 2.4;
ctx.beginPath(); ctx.moveTo(a.x, a.y); ctx.lineTo(b.x, b.y); ctx.stroke();
}
// 节点
line.nodes.forEach(n => {
const p = nodeScreen(n);
ctx.fillStyle = '#e8f0ff'; ctx.strokeStyle = '#0b1020'; ctx.lineWidth = 1.5;
ctx.beginPath(); ctx.arc(p.x, p.y, line.id === state.activeLineId ? 5 : 4, 0, Math.PI * 2);
ctx.fill(); ctx.stroke();
if (state.selectedNode && state.selectedNode.node === n) {
ctx.strokeStyle = '#ffffff'; ctx.lineWidth = 2;
ctx.beginPath(); ctx.arc(p.x, p.y, 8, 0, Math.PI * 2); ctx.stroke();
}
if (state.pair.includes(n)) {
ctx.strokeStyle = '#7dff9b'; ctx.lineWidth = 2;
ctx.beginPath(); ctx.arc(p.x, p.y, 8, 0, Math.PI * 2); ctx.stroke();
}
});
// 世界线编号
if (line.nodes.length) {
const p = nodeScreen(line.nodes[0]);
ctx.fillStyle = 'rgba(255,255,255,0.55)'; ctx.font = '11px Consolas, monospace';
ctx.fillText('线' + line.id, p.x + 8, p.y - 8);
}
});
// 因果对连线
if (state.pair.length === 2) {
const a = nodeScreen(state.pair[0]), b = nodeScreen(state.pair[1]);
ctx.strokeStyle = 'rgba(125,255,155,0.5)'; ctx.setLineDash([4, 4]); ctx.lineWidth = 1.2;
ctx.beginPath(); ctx.moveTo(a.x, a.y); ctx.lineTo(b.x, b.y); ctx.stroke();
ctx.setLineDash([]);
}
}
function draw() {
ctx.setTransform(DPR, 0, 0, DPR, 0, 0);
ctx.clearRect(0, 0, W, H);
ctx.fillStyle = '#0b1020'; ctx.fillRect(0, 0, W, H);
drawSquareGrid();
if (state.view === 'active') drawTiltedGrid(); else drawPrimedAxes();
drawLightRef();
drawAxes();
drawCone();
drawLines();
}
// ------------------------------------------------------------
// 命中检测
// ------------------------------------------------------------
function hitNode(px, py, radius) {
let best = null, bestD = radius;
state.lines.forEach(line => line.nodes.forEach(n => {
const p = nodeScreen(n);
const d = Math.hypot(p.x - px, p.y - py);
if (d < bestD) { bestD = d; best = { line, node: n }; }
}));
return best;
}
function hitSeg(px, py, radius) {
let best = null, bestD = radius;
state.lines.forEach(line => {
for (let i = 0; i < line.nodes.length - 1; i++) {
const a = nodeScreen(line.nodes[i]), b = nodeScreen(line.nodes[i + 1]);
const d = distToSeg(px, py, a, b);
if (d < bestD) { bestD = d; best = { line, i }; }
}
});
return best;
}
function distToSeg(px, py, a, b) {
const vx = b.x - a.x, vy = b.y - a.y;
const L2 = vx * vx + vy * vy;
if (L2 < 1e-9) return Math.hypot(px - a.x, py - a.y);
let u = ((px - a.x) * vx + (py - a.y) * vy) / L2;
u = Math.max(0, Math.min(1, u));
return Math.hypot(px - (a.x + u * vx), py - (a.y + u * vy));
}
// ------------------------------------------------------------
// 指针交互
// ------------------------------------------------------------
let dragNode = null, dragCone = false;
cv.addEventListener('pointerdown', e => {
const r = cv.getBoundingClientRect();
const px = e.clientX - r.left, py = e.clientY - r.top;
if (state.tool === 'pen') {
const line = state.lines.find(l => l.id === state.activeLineId) || newLine();
const w = disp2rest(s2d(px, py));
line.nodes.push({ t: w.t, x: w.x });
draw(); updateInspector();
return;
}
if (state.tool === 'cone') {
const hit = hitNode(px, py, 12);
if (hit) { state.cone = { t: hit.node.t, x: hit.node.x }; dragCone = true; }
else if (state.cone) {
const c = nodeScreen(state.cone);
if (Math.hypot(c.x - px, c.y - py) < 14) dragCone = true;
else { const w = disp2rest(s2d(px, py)); state.cone = { t: w.t, x: w.x }; dragCone = true; }
} else { const w = disp2rest(s2d(px, py)); state.cone = { t: w.t, x: w.x }; dragCone = true; }
draw();
return;
}
// select 模式
const hit = hitNode(px, py, 9);
if (hit) {
if (e.shiftKey) {
if (state.pair.includes(hit.node)) state.pair = state.pair.filter(n => n !== hit.node);
else { state.pair.push(hit.node); if (state.pair.length > 2) state.pair = state.pair.slice(-2); }
} else {
state.selectedNode = hit;
}
dragNode = hit;
cv.setPointerCapture(e.pointerId);
draw(); updateInspector();
return;
}
const seg = hitSeg(px, py, 6);
if (seg) { state.selectedSeg = seg; state.selectedNode = null; draw(); updateInspector(); return; }
state.selectedSeg = null; state.selectedNode = null; state.pair = [];
draw(); updateInspector();
});
cv.addEventListener('pointermove', e => {
if (!dragNode && !dragCone) return;
const r = cv.getBoundingClientRect();
const px = e.clientX - r.left, py = e.clientY - r.top;
const w = disp2rest(s2d(px, py));
if (dragNode) { dragNode.node.t = w.t; dragNode.node.x = w.x; }
if (dragCone && state.cone) { state.cone.t = w.t; state.cone.x = w.x; }
draw(); updateInspector();
});
cv.addEventListener('pointerup', () => { dragNode = null; dragCone = false; });
window.addEventListener('keydown', e => {
if (e.key === 'Escape') { state.selectedNode = null; state.selectedSeg = null; state.pair = []; draw(); updateInspector(); }
if (e.key === 'Delete') deleteSelectedNode();
});
// ------------------------------------------------------------
// 检查器
// ------------------------------------------------------------
function segInfo(a, b) {
const dt = b.t - a.t, dx = b.x - a.x;
const ds2 = dt * dt - dx * dx;
const cls = classify(a, b);
const v = dt !== 0 ? dx / dt : Infinity;
const tau = ds2 > 0 ? Math.sqrt(ds2) : NaN;
return { dt, dx, ds2, cls, v, tau };
}
function updateInspector() {
const insSeg = document.getElementById('ins-seg');
const insNode = document.getElementById('ins-node');
const insPair = document.getElementById('ins-pair');
if (state.selectedSeg) {
const { line, i } = state.selectedSeg;
const a = line.nodes[i], b = line.nodes[i + 1];
if (a && b) {
const s = segInfo(a, b);
insSeg.innerHTML =
`分类:<span class="c-${s.cls}">${CLASS_NAME[s.cls]}</span>\n` +
`Δt = ${fmt(s.dt)} Δx = ${fmt(s.dx)}\n` +
`Δs² = Δt²−Δx² = ${fmt(s.ds2, 3)}\n` +
`v = ${fmt(s.v)} c${Math.abs(s.v) > 1 ? '(超光速→类空)' : ''}\n` +
`τ(固有时)= ${Number.isFinite(s.tau) ? fmt(s.tau, 3) : '—(非类时)'}`;
} else insSeg.innerHTML = '<span class="empty">线段已不存在。</span>';
} else {
insSeg.innerHTML = '<span class="empty">选择模式下点击某条线段,读出 Δt / Δx / Δs² / v / τ。</span>';
}
if (state.selectedNode) {
const n = state.selectedNode.node;
const p = boost(n.t, n.x, state.phi);
insNode.innerHTML =
`S 系:t = ${fmt(n.t)} x = ${fmt(n.x)}\n` +
`S′ 系:t′ = ${fmt(p.t)} x′ = ${fmt(p.x)}`;
} else {
insNode.innerHTML = '<span class="empty">点击节点查看 S 系与 S′ 系坐标。</span>';
}
if (state.pair.length === 2) {
const s = segInfo(state.pair[0], state.pair[1]);
let verdict = '';
if (s.cls === 'time') verdict = '类时间隔:两事件可因果影响;存在某参考系使二者同地。时间顺序在所有参考系中固定。';
else if (s.cls === 'light') verdict = '类光间隔:恰可由光信号联系;任何有质量物体都无法往返。';
else verdict = '类空间隔:两事件互不影响;存在某参考系使二者同时。';
insPair.innerHTML =
`Δt = ${fmt(s.dt)} Δx = ${fmt(s.dx)}\n` +
`Δs² = ${fmt(s.ds2, 3)} → <span class="c-${s.cls}">${CLASS_NAME[s.cls]}</span>\n` +
verdict;
} else {
insPair.innerHTML = '<span class="empty">选择模式下:点击节点选中,Shift+点击第二个节点,判定两事件因果关系。</span>';
}
}
// ------------------------------------------------------------
// 工具栏
// ------------------------------------------------------------
function newLine() {
const line = { id: state.nextLineId++, nodes: [] };
state.lines.push(line);
state.activeLineId = line.id;
return line;
}
function deleteSelectedNode() {
if (!state.selectedNode) return;
const { line, node } = state.selectedNode;
line.nodes = line.nodes.filter(n => n !== node);
if (!line.nodes.length) state.lines = state.lines.filter(l => l !== line);
state.pair = state.pair.filter(n => n !== node);
state.selectedNode = null;
state.selectedSeg = null;
draw(); updateInspector();
}
const toolBtns = { pen: document.getElementById('tool-pen'), select: document.getElementById('tool-select'), cone: document.getElementById('tool-cone') };
Object.keys(toolBtns).forEach(k => toolBtns[k].addEventListener('click', () => {
state.tool = k;
Object.keys(toolBtns).forEach(j => toolBtns[j].classList.toggle('active', j === k));
}));
document.getElementById('btn-newline').addEventListener('click', () => {
newLine();
state.tool = 'pen';
Object.keys(toolBtns).forEach(j => toolBtns[j].classList.toggle('active', j === 'pen'));
draw();
});
document.getElementById('btn-delnode').addEventListener('click', deleteSelectedNode);
// 清空画布:危险操作,自定义 confirm 弹窗(项目规范禁用原生 confirm)
const mask = document.getElementById('confirm-mask');
document.getElementById('btn-clear').addEventListener('click', () => { mask.hidden = false; });
document.getElementById('confirm-no').addEventListener('click', () => { mask.hidden = true; });
document.getElementById('confirm-yes').addEventListener('click', () => {
state.lines = []; state.pair = []; state.selectedNode = null; state.selectedSeg = null; state.cone = null;
newLine();
mask.hidden = true;
draw(); updateInspector();
});
// 视图切换
const viewBtns = { active: document.getElementById('view-active'), passive: document.getElementById('view-passive') };
Object.keys(viewBtns).forEach(k => viewBtns[k].addEventListener('click', () => {
state.view = k;
Object.keys(viewBtns).forEach(j => viewBtns[j].classList.toggle('active', j === k));
draw(); updateInspector();
}));
// boost 滑块与读数
const phiInput = document.getElementById('phi');
const phiRead = document.getElementById('phi-read');
function updatePhiRead() {
phiRead.textContent = `φ=${fmt(state.phi)} v=${fmt(Math.tanh(state.phi))}c`;
}
phiInput.addEventListener('input', () => {
state.phi = parseFloat(phiInput.value) || 0;
updatePhiRead(); draw(); updateInspector();
});
// 扫掠动画:φ 从 0 缓动到滑块目标值
let animId = null;
document.getElementById('btn-sweep').addEventListener('click', () => {
if (animId) cancelAnimationFrame(animId);
const target = state.phi;
const t0 = performance.now(), dur = 1500;
const step = now => {
const k = Math.min(1, (now - t0) / dur);
const e = k < 0.5 ? 2 * k * k : 1 - Math.pow(-2 * k + 2, 2) / 2; // easeInOutQuad
state.phi = target * e;
phiInput.value = String(state.phi);
updatePhiRead(); draw(); updateInspector();
animId = k < 1 ? requestAnimationFrame(step) : null;
};
animId = requestAnimationFrame(step);
});
document.getElementById('btn-clearcone').addEventListener('click', () => { state.cone = null; draw(); });
// ------------------------------------------------------------
// 初始化
// ------------------------------------------------------------
newLine();
resize();
updatePhiRead();
updateInspector();
</script>
</body>
</html>