876a3b8768
新增两个独立思想实验室: 1. 万花筒模拟器:支持可调参数物理万花筒效果,配置方案SQLite持久化 2. 闵可夫斯基画板:支持世界线绘制、洛伦兹变换、光锥因果判定 同时添加自动绑定实验室后端的逻辑,以及配套的单元测试与前端页面
624 lines
25 KiB
HTML
624 lines
25 KiB
HTML
<!-- ============================================================
|
||
思想实验室 · 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|>|Δx|(v<1,可携带因果)</span>
|
||
<span class="c-light">■ 类光 |Δt|=|Δx|(光子世界线)</span>
|
||
<span class="c-space">■ 类空 |Δx|>|Δt|(v>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>
|