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

513 lines
14 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.
<!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:#1a1a1a;
display:flex;
flex-direction:column;
align-items:center;
justify-content:center;
min-height:100vh;
font-family:system-ui;
padding:16px;
}
.kaleido-wrap{
position:relative;
width:520px;
height:520px;
}
#canvas{
border-radius:50%;
box-shadow:0 0 0 16px #6b4423, 0 0 0 22px #4a2e16, 0 12px 45px #000;
cursor:grab;
}
#canvas:active{cursor:grabbing}
.tip{
color:#aaa;
margin-top:12px;
font-size:14px;
line-height:1.6;
text-align:center;
}
.control-panel{
background:#2c2c2c;
padding:14px 20px;
border-radius:10px;
margin-bottom:18px;
color:#eee;
display:flex;
gap:14px;
align-items:center;
flex-wrap:wrap;
justify-content:center;
}
.ctrl-item{
display:flex;
flex-direction:column;
gap:4px;
}
.ctrl-item label{
font-size:13px;
color:#ddd;
}
input[type="range"]{
width:100px;
}
.check-item{
display:flex;
align-items:center;
gap:8px;
}
button{
background:#784c28;
color:#fff;
border:none;
padding:7px 12px;
border-radius:6px;
cursor:pointer;
}
button:hover{background:#8d5b32;}
</style>
</head>
<body>
<div class="control-panel">
<div class="ctrl-item">
<label>碎片数量: <span id="valPieces">120</span></label>
<input id="rangePieces" type="range" min="30" max="260" value="120">
</div>
<div class="ctrl-item">
<label>棱镜分割份数: <span id="valPrism">6</span></label>
<input id="rangePrism" type="range" min="3" max="12" value="6">
</div>
<div class="ctrl-item">
<label>桶宽(视窗孔径): <span id="valTube">1.00</span></label>
<input id="rangeTube" type="range" min="0.40" max="1.00" step="0.02" value="1.00">
</div>
<div class="ctrl-item">
<label>碎片最小倍率: <span id="valScaleMin">0.50x</span></label>
<input id="rangeScaleMin" type="range" min="0.2" max="1.2" step="0.05" value="0.5">
</div>
<div class="ctrl-item">
<label>碎片最大倍率: <span id="valScaleMax">3.00x</span></label>
<input id="rangeScaleMax" type="range" min="1.0" max="4.0" step="0.05" value="3.0">
</div>
<div class="ctrl-item">
<label>重力强度: <span id="valGravity">0.07</span></label>
<input id="rangeGravity" type="range" min="0" max="0.30" step="0.01" value="0.07">
</div>
<div class="ctrl-item">
<label>静摩擦系数: <span id="valFriction">0.11</span></label>
<input id="rangeFriction" type="range" min="0.02" max="0.20" step="0.01" value="0.11">
</div>
<div class="check-item">
<input id="checkGravity" type="checkbox">
<label for="checkGravity">启用重力</label>
</div>
<button id="btnApply">应用设置</button>
<button id="btnShake">手动摇晃万花筒</button>
</div>
<div class="kaleido-wrap">
<canvas id="canvas" width="520" height="520"></canvas>
</div>
<div class="tip">按住鼠标拖动旋转镜筒|双击画面摇晃万花筒</div>
<script>
const canvas = document.getElementById('canvas');
const ctx = canvas.getContext('2d');
const W = canvas.width;
const H = canvas.height;
const cx = W/2, cy = H/2;
const baseRadius = W/2;
let config = {
pieceCount: 120,
prismDiv: 6,
tubeScale: 1.00,
scaleMin: 0.5,
scaleMax: 3.0,
gravityEnabled: false,
gravityStrength: 0.07,
baseStaticFriction: 0.11
};
let pieces = [];
let rotateAngle = 0;
let isDrag = false;
let lastMouseAngle = 0;
let shakeTime = 0;
const SHAKE_DURATION = 700;
const colorPool = [
'#c82423','#f2b138','#287233','#2b549c','#883488','#d86080','#42a5b8','#e6c250','#992840','#366947'
];
/**
碎片概率分配:
50% poly 不规则多角碎片 3‑7边
25% ellipse 椭圆/扁片云母薄片
10% nearCircle 近圆、轻微变形圆
10% mixedDrop 水滴/圆角不规则片/三角/梯形
5% prim:正三角形,正圆,正方形
*/
function makeNaturalShard(){
const r = Math.random();
let type;
if(r < 0.50) type = 'poly';
else if(r < 0.75) type = 'ellipse';
else if(r < 0.85) type = 'nearCircle';
else if(r < 0.95) type = 'mixedDrop';
else type = 'prim';
let shapeData;
if(type === 'poly'){
const sides = 3 + Math.floor(Math.random()*5);
const pts = [];
for(let i=0;i<sides;i++){
const ang = (i / sides) * Math.PI*2 + (Math.random()-0.5)*0.45;
const rad = 4 + Math.random()*6;
pts.push({x:Math.cos(ang)*rad, y:Math.sin(ang)*rad});
}
shapeData = {pts};
}else if(type === 'ellipse'){
const r1 = 3 + Math.random()*7;
const r2 = 1.5 + Math.random()*3.5;
shapeData = {rx:r1, ry:r2, rot:Math.random()*Math.PI};
}else if(type === 'nearCircle'){
const baseR = 3.5 + Math.random()*5;
const pts = [];
const seg = 8;
for(let i=0;i<seg;i++){
const ang = (i/seg)*Math.PI*2;
const rr = baseR * (0.82 + Math.random()*0.36);
pts.push({x:Math.cos(ang)*rr, y:Math.sin(ang)*rr});
}
shapeData = {pts};
}else if(type === 'mixedDrop'){
const sub = Math.floor(Math.random()*3);
const pts = [];
if(sub === 0){
//水滴
const seg = 7;
for(let i=0;i<seg;i++){
const ang = (i/seg)*Math.PI*2;
let rr = 3 + Math.random()*5;
if(ang>0 && ang < Math.PI*0.75) rr *= 1.35;
pts.push({x:Math.cos(ang)*rr, y:Math.sin(ang)*rr});
}
}else if(sub ===1){
//圆角三角
pts.push({x:0,y:-5},{x:-4,y:4},{x:4,y:3});
}else{
//梯形
pts.push({x:-5,y:-3},{x:5,y:-3},{x:3,y:4},{x:-3,y:4});
}
shapeData = {pts};
}else if(type === 'prim'){
const sub = Math.floor(Math.random()*3);
if(sub===0){
//正三角
const pts=[];
for(let i=0;i<3;i++){
const ang = (i/3)*Math.PI*2 - Math.PI/2;
pts.push({x:Math.cos(ang)*5,y:Math.sin(ang)*5});
}
shapeData={pts,primKind:'tri'};
}else if(sub===1){
//正圆
shapeData={primKind:'circle',r:4.5+Math.random()*2};
}else{
//正方形
const s=4.5;
shapeData={pts:[{x:-s,y:-s},{x:s,y:-s},{x:s,y:s},{x:-s,y:s}],primKind:'sq'};
}
}
return {type, shapeData};
}
function resetPieces(){
pieces = [];
const maxPieceRange = baseRadius * config.tubeScale * 0.75;
for(let i=0;i<config.pieceCount;i++){
const s = config.scaleMin + Math.random()*(config.scaleMax - config.scaleMin);
const shard = makeNaturalShard();
pieces.push({
x: (Math.random()-0.5)*maxPieceRange,
y: (Math.random()-0.5)*maxPieceRange,
shard: shard,
sizeScale: s,
rot: Math.random()*Math.PI*2,
color: colorPool[Math.floor(Math.random()*colorPool.length)],
alpha: 0.62 + Math.random()*0.32,
vx: 0,
vy: 0,
vrot: 0,
mass: s,
isSleep: false
})
}
}
function startShake(){
shakeTime = SHAKE_DURATION;
pieces.forEach(p=>{
p.vx = (Math.random()-0.5)*14;
p.vy = (Math.random()-0.5)*14;
p.vrot = (Math.random()-0.5)*0.2;
p.isSleep = false;
})
}
function updatePhysics(dt){
const maxR = baseRadius * config.tubeScale * 0.75;
const dtFactor = dt / 16.67;
pieces.forEach(p=>{
if(p.isSleep && !shakeTime) return;
let gx = 0, gy = 0;
if(config.gravityEnabled){
gx = Math.sin(rotateAngle) * config.gravityStrength * p.mass;
gy = Math.cos(rotateAngle) * config.gravityStrength * p.mass;
}
p.vx += gx * dtFactor;
p.vy += gy * dtFactor;
p.x += p.vx * dtFactor;
p.y += p.vy * dtFactor;
p.rot += p.vrot * dtFactor;
p.vx *= 0.975;
p.vy *= 0.975;
p.vrot *= 0.975;
const velMag = Math.hypot(p.vx, p.vy);
const pieceStaticFric = config.baseStaticFriction + (1.0 / p.sizeScale) * 0.08;
const gravityDrive = Math.hypot(gx, gy);
if(velMag < pieceStaticFric && gravityDrive < pieceStaticFric){
p.vx = 0;
p.vy = 0;
p.vrot *= 0.6;
if(!shakeTime) p.isSleep = true;
}
const dist = Math.hypot(p.x, p.y);
if(dist > maxR){
const scale = maxR / dist;
p.x *= scale;
p.y *= scale;
const nx = p.x / maxR;
const ny = p.y / maxR;
const dot = p.vx * nx + p.vy * ny;
if(dot>0){
p.vx -= 1.15*dot*nx;
p.vy -= 1.15*dot*ny;
p.vx *=0.82;
p.vy *=0.82;
p.vrot *=0.85;
}
}
})
}
function drawGlassPiece(p){
ctx.save();
ctx.translate(p.x, p.y);
ctx.rotate(p.rot);
ctx.scale(p.sizeScale, p.sizeScale);
ctx.globalAlpha = p.alpha;
ctx.fillStyle = p.color;
const shard = p.shard;
const sd = shard.shapeData;
if(shard.type === 'ellipse'){
ctx.beginPath();
ctx.rotate(sd.rot);
ctx.ellipse(0,0, sd.rx, sd.ry,0,0,Math.PI*2);
ctx.fill();
}else if(shard.type === 'prim' && sd.primKind === 'circle'){
ctx.beginPath();
ctx.arc(0,0,sd.r,0,Math.PI*2);
ctx.fill();
}else{
ctx.beginPath();
ctx.moveTo(sd.pts[0].x, sd.pts[0].y);
for(let i=1;i<sd.pts.length;i++){
ctx.lineTo(sd.pts[i].x, sd.pts[i].y);
}
ctx.closePath();
ctx.fill();
}
ctx.restore();
}
function drawKaleido(){
ctx.clearRect(0,0,W,H);
const viewRadius = baseRadius * config.tubeScale;
ctx.save();
ctx.translate(cx,cy);
ctx.rotate(rotateAngle);
ctx.beginPath();
ctx.arc(0,0, viewRadius, 0, Math.PI*2);
ctx.clip();
const seg = Math.PI * 2 / config.prismDiv;
for(let s=0;s<config.prismDiv;s++){
ctx.save();
ctx.rotate(seg * s);
if(s%2===1){
ctx.scale(1,-1);
}
pieces.forEach(p=> drawGlassPiece(p));
ctx.restore();
}
ctx.restore();
ctx.save();
ctx.globalCompositeOperation = 'screen';
ctx.filter = 'blur(3.2px)';
ctx.globalAlpha = 0.20;
ctx.drawImage(canvas,0,0);
ctx.filter = 'none';
const grad = ctx.createRadialGradient(cx,cy, viewRadius*0.62, cx,cy, viewRadius);
grad.addColorStop(0,'rgba(0,0,0,0)');
grad.addColorStop(1,'rgba(0,0,0,0.48)');
ctx.globalCompositeOperation='multiply';
ctx.globalAlpha=1;
ctx.fillStyle = grad;
ctx.beginPath();
ctx.arc(cx,cy, viewRadius,0,Math.PI*2);
ctx.fill();
ctx.globalCompositeOperation='source-over';
ctx.fillStyle="#000000";
ctx.beginPath();
ctx.arc(cx,cy,baseRadius,0,Math.PI*2);
ctx.arc(cx,cy,viewRadius,0,Math.PI*2,true);
ctx.fill();
ctx.restore();
}
function getMouseAngle(e){
const rect = canvas.getBoundingClientRect();
const t = e.touches ? e.touches[0] : e;
const mx = t.clientX - rect.left - cx;
const my = t.clientY - rect.top - cy;
return Math.atan2(my,mx);
}
canvas.addEventListener('mousedown',(e)=>{
isDrag = true;
lastMouseAngle = getMouseAngle(e);
})
window.addEventListener('mousemove',(e)=>{
if(!isDrag) return;
const nowAngle = getMouseAngle(e);
rotateAngle += (nowAngle - lastMouseAngle);
lastMouseAngle = nowAngle;
pieces.forEach(p=>p.isSleep=false);
})
window.addEventListener('mouseup',()=>isDrag=false);
canvas.addEventListener('dblclick', startShake);
canvas.addEventListener('touchstart',(e)=>{
e.preventDefault();
isDrag=true;
lastMouseAngle = getMouseAngle(e);
})
canvas.addEventListener('touchmove',(e)=>{
e.preventDefault();
if(!isDrag) return;
const nowAngle = getMouseAngle(e);
rotateAngle += (nowAngle - lastMouseAngle);
lastMouseAngle = nowAngle;
pieces.forEach(p=>p.isSleep=false);
})
canvas.addEventListener('touchend',()=>isDrag=false);
let lastTap=0;
canvas.addEventListener('touchstart',()=>{
const now=Date.now();
if(now-lastTap<320) startShake();
lastTap=now;
})
const rangePieces = document.getElementById('rangePieces');
const rangePrism = document.getElementById('rangePrism');
const rangeTube = document.getElementById('rangeTube');
const rangeScaleMin = document.getElementById('rangeScaleMin');
const rangeScaleMax = document.getElementById('rangeScaleMax');
const rangeGravity = document.getElementById('rangeGravity');
const rangeFriction = document.getElementById('rangeFriction');
const checkGravity = document.getElementById('checkGravity');
const valPieces = document.getElementById('valPieces');
const valPrism = document.getElementById('valPrism');
const valTube = document.getElementById('valTube');
const valScaleMin = document.getElementById('valScaleMin');
const valScaleMax = document.getElementById('valScaleMax');
const valGravity = document.getElementById('valGravity');
const valFriction = document.getElementById('valFriction');
const btnApply = document.getElementById('btnApply');
const btnShake = document.getElementById('btnShake');
rangePieces.addEventListener('input',()=> valPieces.innerText = rangePieces.value);
rangePrism.addEventListener('input',()=> valPrism.innerText = rangePrism.value);
rangeTube.addEventListener('input',()=> valTube.innerText = Number(rangeTube.value).toFixed(2));
rangeScaleMin.addEventListener('input',()=> valScaleMin.innerText = Number(rangeScaleMin.value).toFixed(2)+'x');
rangeScaleMax.addEventListener('input',()=> valScaleMax.innerText = Number(rangeScaleMax.value).toFixed(2)+'x');
rangeGravity.addEventListener('input',()=> valGravity.innerText = Number(rangeGravity.value).toFixed(2));
rangeFriction.addEventListener('input',()=> valFriction.innerText = Number(rangeFriction.value).toFixed(2));
btnApply.addEventListener('click',()=>{
config.pieceCount = Number(rangePieces.value);
config.prismDiv = Number(rangePrism.value);
config.tubeScale = Number(rangeTube.value);
config.scaleMin = Number(rangeScaleMin.value);
config.scaleMax = Number(rangeScaleMax.value);
config.gravityEnabled = checkGravity.checked;
config.gravityStrength = Number(rangeGravity.value);
config.baseStaticFriction = Number(rangeFriction.value);
if(config.scaleMin>config.scaleMax){
const tmp = config.scaleMin;
config.scaleMin = config.scaleMax;
config.scaleMax = tmp;
}
resetPieces();
})
btnShake.addEventListener('click', startShake);
let lastTime=0;
function animate(time){
const dt = time-lastTime;
lastTime=time;
rotateAngle += (Math.random()-0.5)*0.0006;
if(shakeTime > 0) shakeTime -= dt;
updatePhysics(dt);
drawKaleido();
requestAnimationFrame(animate);
}
resetPieces();
requestAnimationFrame(animate);
</script>
</body>
</html>